refactor(android): 改用设备签名拦截器认证,移除 challenge 往返

设备接口统一由 AuthInterceptor 注入 Device-Sig 头完成认证,删除手动 challenge 签名流程与 transport-key 接口,注册改用本地生成 ts:nonce 做 PoP 验签。
This commit is contained in:
TongTongStudio
2026-08-27 08:43:02 +08:00
parent dbb75658a6
commit 586e5d9066
22 changed files with 600 additions and 1078 deletions

View File

@@ -62,19 +62,22 @@
### Phase 1设备注册 + 用户绑定
> **方案 AHeader 设备签名认证)**:设备不再先 `GET /api/device/challenge`。设备本地生成
> `ts:nonce`,用 TEE 私钥对 `METHOD|path|ts|nonce` 做 RSA-PSS 签名,写入请求头
> `Authorization: Device-Sig sn=...,ts=...,nonce=...,sig=...`;服务端 `DeviceAuthInterceptor`
> 统一验签(时效 + nonce 防重放 + 验签),业务接口零额外往返。仅注册接口用请求内公钥做 PoP 验签。
```
设备端 后端 用户端
│ │ │
│── 生成 TEE 密钥对 ──────────│ │
│── GET /api/device/challenge│
◄── {challenge} ────────────│
│── POST /api/device/register│ │
│ {sn,pubKey,challenge,sig}──►│── PoP 验签(challenge,sig) │
│── POST /api/device/register│ challenge=ts:nonce 由设备本地生成并自签名)
{sn,pubKey,challenge,sig}──►│── PoP 验签(用请求内公钥)
│ │── 存储 device(sn, pubKey) │
│◄── {deviceId} ─────────────│ │
│ │ │
│◄── POST /api/device/bind
│ {userId, sn}
POST /api/device/bind │ 每个设备接口自动携带
(Device-Sig 头 + {userId}) ──►│── DeviceAuthInterceptor 验签
│ │── 绑定 userId ↔ deviceId │
│ │◄── {ok} ────────────────────│
```
@@ -102,9 +105,7 @@
设备端(新) 后端 用户端
│ │ │
│── 新 TEE 密钥对 │ │
│── GET /api/device/challenge │
│◄── {challenge} ─────────────│ │
│── POST /api/device/register │ │
│── POST /api/device/register │ challenge=ts:nonce 本地自签名)
│ {sn,newPubKey,challenge,sig}►│PoP 验签 + 旧设备自动停用) │
│◄── {newDeviceId} ───────────│ │
│ │ │
@@ -372,14 +373,24 @@ TEE 密钥是「**门禁卡**」UK / DEK 才是「**保险柜钥匙**」。
### 设备端(无登录,信任边界 = TEE 密钥 / SN / Recovery Token
> 设备端所有接口除 `/api/device/register` 外,均由 `AuthInterceptor` 自动注入
> `Authorization: Device-Sig sn=...,ts=...,nonce=...,sig=...` 完成设备签名认证(方案 A
> 零 challenge 往返),请求体只含业务字段。
| Method | Path | 说明 |
|---|---|---|
| GET | `/api/device/status?sn=xxx` | 查询设备注册/绑定状态App 启动复用,免重复注册绑定) |
| POST | `/api/device/register` | 设备注册SN + 公钥 + PoP 验签 `{challenge, signature}`,幂等:同 SN 同公钥返回原 deviceId |
| POST | `/api/photo/upload` | 上传加密照片(设备签名验签 |
| POST | `/api/photo/recover` | 恢复后获取照片 DEK |
| POST | `/api/device/status` | 查询设备注册/绑定状态App 启动复用,免重复注册绑定) |
| POST | `/api/device/register` | 设备注册SN + 公钥 + PoP 验签 `{challenge, signature}`challenge 由设备本地生成 `ts:nonce`幂等:同 SN 同公钥返回原 deviceId |
| POST | `/api/device/bind` | 绑定设备(`{userId}`设备签名认证 |
| POST | `/api/device/sms/send` | 发送短信验证码(设备签名认证防轰炸) |
| POST | `/api/device/recover` | 恢复授权(短信 + SN 双因子,设备签名认证) |
| POST | `/api/device/photos` | 本设备绑定用户的照片 ID 列表 |
| POST | `/api/device/photos/metadata` | 本设备绑定用户的照片元数据 |
| POST | `/api/device/photo/{photoId}/local` | 设备本地下载解密单张照片(端到端加密) |
| POST | `/api/photo/upload` | 上传加密照片(设备签名 + 元数据验签) |
| POST | `/api/photo/recover` | 恢复后获取照片 DEK设备签名认证 |
> **注册/绑定幂等说明**`/api/device/register` 对「同一 SN + 相同公钥」幂等——App 重启后重复注册不会生成新 deviceId也不会误停用自己仅当公钥变化恢复出厂时才停旧换新。注册必须携带 PoP先 `GET /api/device/challenge`用 TEE 私钥对 challenge 签名,服务端验签通过才允许注册,防止攻击者用自己公钥冒名注册受害者 SNP0-1 修复)。App 启动时建议先调 `GET /api/device/status?sn=xxx`,若 `registered=true` 且 `bound=true`,直接复用返回的 `deviceId`/`userId`,跳过注册与绑定。
> **注册/绑定幂等说明**`/api/device/register` 对「同一 SN + 相同公钥」幂等——App 重启后重复注册不会生成新 deviceId也不会误停用自己仅当公钥变化恢复出厂时才停旧换新。注册必须携带 PoPchallenge 由设备本地生成 `ts:nonce`,用 TEE 私钥签名,服务端用请求内公钥验签),防止攻击者用自己公钥冒名注册受害者 SN。App 启动时建议先调 `POST /api/device/status`,若 `registered=true` 且 `bound=true`,直接复用返回的 `deviceId`/`userId`,跳过注册与绑定。
### 用户端(需要登录 Token

View File

@@ -12,9 +12,7 @@ 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.DevicePhotoResponse;
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;
@@ -34,10 +32,12 @@ import com.secure.device.DeviceCrypto;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
@@ -150,6 +150,8 @@ public class MainViewModel extends BaseViewModel {
crypto = new DeviceCrypto(app); // 内部 containsAlias 已保证:存在则不重新生成
sn = crypto.getDeviceSN();
pubKey = crypto.getPublicKeyBase64();
// 方案 A把 TEE 载体与 SN 注入网络层,此后所有设备接口由拦截器自动签名,无需再手动取 challenge
ApiClient.configureDeviceAuth(crypto, sn);
return new SelfTestResult(alreadyExists, localSelfTest(crypto, pubKey));
})
.subscribeOn(Schedulers.computation())
@@ -221,24 +223,12 @@ 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},验签通过后才返回状态
* 查询云端设备状态(方案 A设备签名认证由拦截器自动注入 Header零 challenge 往返)。
*/
private void queryDeviceStatus() {
if (!requireCrypto()) return;
Disposable d = ApiClient.deviceApi().deviceStatusChallenge()
Disposable d = ApiClient.deviceApi().deviceStatus()
.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,7 +239,6 @@ public class MainViewModel extends BaseViewModel {
// 已注册且已绑定:复用,跳到可用状态
userId = s.getUserId();
bound = true;
ApiClient.saveUserId(userId); // 后续鉴权接口自动携带 X-User-Id
ui.appendLog("✅ 云端已有注册/绑定记录已复用deviceId=" + deviceId
+ "userId=" + userId + "(免重新注册绑定)");
ui.appendLog(" 可直接点击「选择并上传图片」继续,或点击「下载解密照片」");
@@ -303,24 +292,22 @@ public class MainViewModel extends BaseViewModel {
}
/**
* 构造带 PoP 签名的设备注册请求链
* 1. GET /api/device/challenge 获取一次性挑战值
* 2. 用 TEE 私钥对 challenge 签名
* 3. POST /api/device/register 携带 {sn, publicKeyBase64, challenge, signature}
* 4. 解包统一响应,返回业务数据 RegisterResponse
* 构造带 PoP 签名的设备注册请求链(方案 Achallenge 由设备本地生成,零 GET /challenge 往返)。
*
* challenge 格式 {@code ts:nonce}:设备本地取当前毫秒时间戳 + 随机 nonce
* 用 TEE 私钥签名后上报服务端用「请求内上传的公钥」验签PoP
* 证明请求方确实拥有该公钥对应私钥,防止冒名注册。
*
* 供首次注册与恢复出厂后重新注册复用。
*/
private Single<RegisterResponse> registerDeviceWithPop() {
return ApiClient.deviceApi().deviceStatusChallenge()
.map(ApiResponse::getData)
.flatMap(ch -> {
String challenge = ch.getChallenge();
String sig = crypto.signMetadata(challenge); // TEE 私钥签名PoP
RegisterRequest body = new RegisterRequest(sn, pubKey, challenge, sig);
return ApiClient.deviceApi().registerDevice(body)
.map(ApiResponse::getData);
});
return Single.fromCallable(() -> {
String challenge = generateLocalChallenge(); // ts:nonce
String sig = crypto.signMetadata(challenge); // TEE 私钥签名PoP
return new RegisterRequest(sn, pubKey, challenge, sig);
}).subscribeOn(Schedulers.computation())
.flatMap(body -> ApiClient.deviceApi().registerDevice(body)
.map(ApiResponse::getData));
}
// ==================== ② 绑定用户 ====================
@@ -337,18 +324,11 @@ public class MainViewModel extends BaseViewModel {
if (!requireCrypto()) return;
this.userId = userId;
this.phone = phone;
ui.appendLog("—— ② 绑定用户(设备签名认证)——");
ui.appendLog("—— ② 绑定用户(设备签名认证由拦截器自动注入)——");
runStep("绑定用户",
// 取 challenge → TEE 签名 → 设备签名认证绑定(不再依赖 X-User-Id 自报身份)
ApiClient.deviceApi().deviceStatusChallenge()
.map(ApiResponse::getData)
.map(ChallengeResponse::getChallenge)
.map(challenge -> {
String signature = crypto.signMetadata(challenge);
return new BindRequest(sn, challenge, signature, userId, phone);
})
.flatMap(req -> ApiClient.deviceApi().bindDevice(req)
.map(ApiResponse::getData)),
// 设备签名认证由 AuthInterceptor 自动注入 Header请求体只含业务字段
ApiClient.deviceApi().bindDevice(new BindRequest(sn, userId, phone))
.map(ApiResponse::getData),
(MessageResponse r) -> {
bound = true;
ui.appendLog("✅ 绑定成功: " + userId + "" + sn
@@ -429,18 +409,12 @@ public class MainViewModel extends BaseViewModel {
// lastDekBase64 = p.dekBase64;
ui.appendLog(" 图片加密: photoId=" + photoId
+ ",大小=" + imageBytes.length + " 字节DEK 已生成(准备用服务端传输公钥加密上传");
// 获取服务端传输公钥,用其加密 DEK使 DEK 在网络上不明文(纵深防御)
String serverTransportKey = ApiClient.deviceApi().transportPublicKey()
.map(ApiResponse::getData)
.map(d -> d.get("publicKeyBase64"))
.blockingGet();
String encryptedDek = crypto.encryptDekWithTransportKey(
Base64.getDecoder().decode(p.dekBase64), serverTransportKey);
+ ",大小=" + imageBytes.length + " 字节DEK 已生成(随包上传,服务端用 UK 信封加密存储");
// 方案 A移除「服务端传输公钥」额外往返接口DEK 明文随包上传。
// 生产环境应启用 HTTPS 保证传输机密性。
UploadPhotoRequest req = new UploadPhotoRequest(sn, photoId,
p.ciphertextBase64, p.ivBase64, encryptedDek, sig, metadata);
p.ciphertextBase64, p.ivBase64, p.dekBase64, sig, metadata);
return ApiClient.deviceApi().uploadPhoto(req)
.map(ApiResponse::getData); // 解包统一响应,返回业务数据
}
@@ -465,9 +439,9 @@ public class MainViewModel extends BaseViewModel {
}
ui.appendLog("—— ④ 设备本地下载解密最近一张照片 ——");
runStep("下载解密",
// 1. 设备签名认证拉取本设备照片列表
deviceSignedAuthFlatMap(auth -> ApiClient.deviceApi().devicePhotos(auth)
.map(ApiResponse::getData))
// 1. 设备签名认证(拦截器注入 Header拉取本设备照片列表
ApiClient.deviceApi().devicePhotos()
.map(ApiResponse::getData)
.flatMap(photos -> {
List<String> ids = photos.getPhotos();
if (ids == null || ids.isEmpty()) {
@@ -551,18 +525,11 @@ public class MainViewModel extends BaseViewModel {
}
if (!requireCrypto()) return;
this.phone = phone;
ui.appendLog("—— ⑦ 发送短信验证码 → " + phone + "(设备签名认证)——");
ui.appendLog("—— ⑦ 发送短信验证码 → " + phone + "(设备签名认证由拦截器自动注入)——");
runStep("发送短信",
// 取 challenge → TEE 签名 → 设备签名认证发短信(防轰炸,不再依赖 X-User-Id
ApiClient.deviceApi().deviceStatusChallenge()
.map(ApiResponse::getData)
.map(ChallengeResponse::getChallenge)
.map(challenge -> {
String signature = crypto.signMetadata(challenge);
return new SmsRequest(sn, challenge, signature, phone);
})
.flatMap(req -> ApiClient.deviceApi().sendSms(req)
.map(ApiResponse::getData)),
// 设备签名认证由 AuthInterceptor 自动注入 Header请求体只含手机号
ApiClient.deviceApi().sendSms(new SmsRequest(phone))
.map(ApiResponse::getData),
(MessageResponse s) -> {
ui.appendLog("✅ 短信已发送: " + s.getMessage()
+ "demo 后端固定验证码 000000见后端控制台");
@@ -588,15 +555,8 @@ public class MainViewModel extends BaseViewModel {
// 在 flatMap 作用域内暂存服务端返回的新设备 id响应对象 r 在成功回调不可见)
final String[] newDeviceId = new String[1];
runStep("恢复授权",
// 取 challenge → TEE 签名 → 设备签名认证恢复(不再依赖 X-User-Id
ApiClient.deviceApi().deviceStatusChallenge()
.map(ApiResponse::getData)
.map(ChallengeResponse::getChallenge)
.map(challenge -> {
String signature = crypto.signMetadata(challenge);
return new RecoverRequest(sn, challenge, signature, smsCode, pubKey);
})
.flatMap(req -> ApiClient.deviceApi().recoverDevice(req))
// 设备签名认证由 AuthInterceptor 自动注入 Header请求体只含业务字段
ApiClient.deviceApi().recoverDevice(new RecoverRequest(sn, smsCode, pubKey))
.flatMap(r -> Single.fromCallable(() -> {
newDeviceId[0] = r.getData().getDeviceId();
byte[] tokenBytes = Base64.getDecoder()
@@ -662,8 +622,8 @@ public class MainViewModel extends BaseViewModel {
}
ui.appendLog("—— ④b 设备本地下载解密最近一张照片 ——");
ui.setBusy(true);
Disposable d = deviceSignedAuthFlatMap(auth -> ApiClient.deviceApi().devicePhotos(auth)
.map(ApiResponse::getData))
Disposable d = ApiClient.deviceApi().devicePhotos()
.map(ApiResponse::getData)
.flatMap(photos -> {
List<String> ids = photos.getPhotos();
if (ids == null || ids.isEmpty()) {
@@ -709,10 +669,10 @@ public class MainViewModel extends BaseViewModel {
ui.postToast("设备尚未初始化");
return;
}
ui.appendLog("—— 列出全部照片元数据(设备签名认证)——");
ui.appendLog("—— 列出全部照片元数据(设备签名认证由拦截器注入)——");
runStep("列出照片",
deviceSignedAuthFlatMap(auth -> ApiClient.deviceApi().devicePhotosMetadata(auth)
.map(ApiResponse::getData)),
ApiClient.deviceApi().devicePhotosMetadata()
.map(ApiResponse::getData),
(PhotoMetadataResponse r) -> {
if (r.getPhotos() == null || r.getPhotos().isEmpty()) {
ui.appendLog(" 暂无照片,请先在设备端「选择并上传图片」");
@@ -753,8 +713,8 @@ public class MainViewModel extends BaseViewModel {
}
ui.appendLog("—— 设备本地下载解密全部照片(端到端加密,服务端不见明文)——");
ui.setBusy(true);
Disposable d = deviceSignedAuthFlatMap(auth -> ApiClient.deviceApi().devicePhotos(auth)
.map(ApiResponse::getData))
Disposable d = ApiClient.deviceApi().devicePhotos()
.map(ApiResponse::getData)
// 2. 逐张本地解密
.flatMap(photos -> Single.fromCallable(() -> {
List<String> ids = photos.getPhotos();
@@ -807,49 +767,16 @@ public class MainViewModel extends BaseViewModel {
}
/**
* 构造设备签名认证请求体Challenge-Response 第一步 + 第二步)。
* 取一次性 challenge → 用 TEE 私钥签名 → 返回 {sn, challenge, signature}
* 阻塞式blockingGet供同步调用场景使用。
*/
private DeviceStatusRequest deviceSignedAuth() {
String challenge = ApiClient.deviceApi().deviceStatusChallenge()
.map(ApiResponse::getData)
.map(ChallengeResponse::getChallenge)
.blockingGet();
String signature = crypto.signMetadata(challenge);
return new DeviceStatusRequest(sn, challenge, signature);
}
/**
* 将「设备签名认证」接入 RxJava 链:先异步取 challenge → TEE 签名 → 交给 mapper 发起后续请求。
* 避免在 IO 链上阻塞取 challenge适合 runStep / flatMap 场景。
*
* @param mapper 收到签名认证请求体后返回后续网络请求 Single
*/
private <R> Single<R> deviceSignedAuthFlatMap(
io.reactivex.rxjava3.functions.Function<DeviceStatusRequest, Single<R>> mapper) {
return ApiClient.deviceApi().deviceStatusChallenge()
.map(ApiResponse::getData)
.map(ChallengeResponse::getChallenge)
.map(challenge -> {
String signature = crypto.signMetadata(challenge);
return new DeviceStatusRequest(sn, challenge, signature);
})
.flatMap(mapper);
}
/**
* 设备本地解密单张照片:先取 challenge → TEE 签名 → 调后端下发密文+加密DEK+IV → 本地解密。
* 设备本地解密单张照片:设备签名认证由拦截器自动注入 Header调用后端下发密文+加密DEK+IV
* 设备在 TEE 内本地解密
*
* @return 明文照片字节
*/
private byte[] decryptPhotoLocal(String photoId) {
// 1. 设备签名认证Challenge-Response证明持有该 SN 设备私钥)
DeviceStatusRequest auth = deviceSignedAuth();
// 1. 设备签名认证由 AuthInterceptor 自动注入(证明持有该 SN 设备私钥)
// 2. 调用设备本地解密接口,下发密文 + 设备公钥加密的 DEK + IV
DevicePhotoResponse resp = ApiClient.deviceApi()
.deviceDownloadLocal(photoId, auth)
.deviceDownloadLocal(photoId)
.map(ApiResponse::getData)
.blockingGet();
@@ -955,6 +882,16 @@ public class MainViewModel extends BaseViewModel {
}
}
/**
* 生成设备本地一次性 challengePoP 用),格式 {@code <timestampMillis>:<nonce>}。
* 服务端按时间戳校验时效、按 nonce 校验一次性(防重放),与注册 PoP 验签流程对齐。
*/
private String generateLocalChallenge() {
String ts = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString().replace("-", "");
return ts + ":" + nonce;
}
/** 设备未就绪时给出提示并返回 false避免抛异常导致界面假死 */
private boolean requireCrypto() {
if (crypto == null) {

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import android.util.Log;
import com.secure.demo.BuildConfig;
import com.secure.device.DeviceCrypto;
import java.util.concurrent.TimeUnit;
@@ -108,17 +109,16 @@ public final class ApiClient {
return deviceApi;
}
/** 保存当前用户 ID绑定成功后调用AuthInterceptor 将自动注入 X-User-Id */
public static void saveUserId(String userId) {
/**
* 配置设备签名认证(方案 AHeader 自动签名)。
* 需在设备本地 TEE 初始化完成后调用一次,此后所有设备接口由拦截器自动注入
* {@code Authorization: Device-Sig ...},无需再手动取 challenge。
*/
public static void configureDeviceAuth(DeviceCrypto crypto, String sn) {
if (authInterceptor == null) {
throw new IllegalStateException("ApiClient.init(Context) 必须先调用");
}
authInterceptor.saveUserId(userId);
}
/** 读取当前用户 ID照片下载/恢复等鉴权接口使用) */
public static String getUserId() {
return authInterceptor != null ? authInterceptor.getUserId() : null;
authInterceptor.configureDeviceAuth(crypto, sn);
}
/** 注册界面网络日志监听App 日志区实时显示每次请求),传 null 注销 */

View File

@@ -1,45 +1,101 @@
package com.secure.demo.network;
import android.content.Context;
import android.content.SharedPreferences;
import com.secure.device.DeviceCrypto;
import java.io.IOException;
import java.security.SecureRandom;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
/**
* 认证拦截器(零信任版本)。
* 设备签名认证拦截器(零信任,方案 A)。
*
* 设备端Android一律走「设备签名认证」SN + challenge + signature由各接口在请求体内
* 携带签名;后端已移除对 X-User-Id 的信任,因此本拦截器**不再注入任何自报身份头**。
* 此处仅保留本地 userId 的持久化存取,供 UI 展示 / 日志使用,不参与任何鉴权。
* <p>替代旧的「先 GET /challenge 再签名」两段式流程:本拦截器在每个需要设备认证的请求上,
* 自动用 TEE 私钥对 {@code METHOD|path|ts|nonce} 签名,并写入请求头:</p>
* <pre>
* Authorization: Device-Sig sn=&lt;sn&gt;,ts=&lt;ms&gt;,nonce=&lt;hex&gt;,sig=&lt;base64&gt;
* </pre>
* <p>服务端 {@code DeviceAuthInterceptor} 统一验签(时效 + nonce 防重放 + RSA-PSS
* 业务接口零额外认证代码、零额外往返。</p>
*
* <p>仅对设备签名认证路径注入头注册接口PoP用请求内公钥验签显式排除。</p>
*/
public class AuthInterceptor implements Interceptor {
public static final String PREFS_NAME = "secure_device_prefs";
public static final String KEY_USER_ID = "user_id";
private static final String HEADER_PREFIX = "Device-Sig ";
private static final String ATTR_SN = "sn";
private static final String ATTR_TS = "ts";
private static final String ATTR_NONCE = "nonce";
private static final String ATTR_SIG = "sig";
private final Context appContext;
// 设备签名认证所需的 TEE 载体与 SN由 ApiClient.configureDeviceAuth 注入)
private volatile DeviceCrypto deviceCrypto;
private volatile String deviceSn;
public AuthInterceptor(Context context) {
this.appContext = context.getApplicationContext();
}
/**
* 配置设备签名认证所需的 TEE 载体与 SN在设备本地 TEE 初始化完成后调用)。
*/
public void configureDeviceAuth(DeviceCrypto crypto, String sn) {
this.deviceCrypto = crypto;
this.deviceSn = sn;
}
@Override
public Response intercept(Chain chain) throws IOException {
// 零信任:不注入 X-User-Id / 任何自报身份头,设备身份一律由请求体签名认证证明。
return chain.proceed(chain.request());
Request request = chain.request();
String path = request.url().encodedPath();
// 仅对需要设备签名认证的路径注入头注册接口PoP与公开接口排除
if (deviceCrypto != null && deviceSn != null && isDeviceSignedPath(path)) {
request = request.newBuilder()
.header("Authorization", buildAuthHeader(request.method(), request.url().encodedPath()))
.build();
}
return chain.proceed(request);
}
public void saveUserId(String userId) {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_USER_ID, userId).apply();
/**
* 是否需要设备签名认证头。
* 设备接口(/api/device/** 与 /api/photo/{upload,recover})注入签名头;
* 排除注册PoP设备未入库与 Web 用户端的 Bearer 接口(/api/photo/{id}/decrypt*)。
*/
private boolean isDeviceSignedPath(String path) {
if (path.startsWith("/api/device/")) {
return true;
}
// /api/photo/ 下仅 upload 与 recover 需要设备签名
return path.equals("/api/photo/upload") || path.equals("/api/photo/recover");
}
public String getUserId() {
SharedPreferences sp = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
return sp.getString(KEY_USER_ID, null);
/**
* 构造 {@code Authorization: Device-Sig sn=...,ts=...,nonce=...,sig=...}。
* 签名原文 = METHOD|path|ts|nonce与服务端 DeviceAuthInterceptor 完全一致)。
*/
private String buildAuthHeader(String method, String path) {
String ts = String.valueOf(System.currentTimeMillis());
String nonce = randomHex(16);
String canonical = method + "|" + path + "|" + ts + "|" + nonce;
String sig = deviceCrypto.signMetadata(canonical); // TEE 私钥 RSA-PSS 签名
return HEADER_PREFIX + ATTR_SN + "=" + deviceSn
+ "," + ATTR_TS + "=" + ts
+ "," + ATTR_NONCE + "=" + nonce
+ "," + ATTR_SIG + "=" + sig;
}
private String randomHex(int numBytes) {
byte[] bytes = new byte[numBytes];
new SecureRandom().nextBytes(bytes);
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}

View File

@@ -2,9 +2,7 @@ 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.DevicePhotoResponse;
@@ -29,42 +27,40 @@ import retrofit2.http.POST;
import retrofit2.http.Path;
import retrofit2.http.Streaming;
import java.util.Map;
/**
* 设备安全后端 API 接口定义。
* 对应 springboot-server 的 DeviceController 全部接口。
*
* 前后端分离后所有接口统一返回 {@link ApiResponse}{code, message, data}
* 业务数据通过 {@link ApiResponse#getData()} 获取。
*
* 设备签名认证(方案 A由 {@link AuthInterceptor} 自动注入 Header
* 因此设备接口不再携带 challenge/signature 请求体,也不再有 GET /challenge 与
* GET /transport-key 两个额外往返接口。
*/
public interface DeviceApi {
/** 0a. 获取设备状态查询挑战值Challenge-Response 第一步 */
@GET("api/device/challenge")
Single<ApiResponse<ChallengeResponse>> deviceStatusChallenge();
/** 0b. 查询设备注册/绑定状态(设备端无登录,需设备签名认证) */
/** 查询设备注册/绑定状态(设备签名认证由拦截器注入,无需请求体 */
@POST("api/device/status")
Single<ApiResponse<DeviceStatusResponse>> deviceStatus(@Body DeviceStatusRequest body);
Single<ApiResponse<DeviceStatusResponse>> deviceStatus();
/** 1. 设备注册SN + 公钥,设备端无登录) */
/** 1. 设备注册SN + 公钥 + PoP 自签名 challenge,设备端无登录) */
@POST("api/device/register")
Single<ApiResponse<RegisterResponse>> registerDevice(@Body RegisterRequest body);
/** 2. 绑定设备(零信任:设备签名认证sn + challenge + signature + userId */
/** 2. 绑定设备(设备签名认证由拦截器注入 */
@POST("api/device/bind")
Single<ApiResponse<MessageResponse>> bindDevice(@Body BindRequest body);
/** 3. 发送短信验证码(零信任:设备签名认证,防短信轰炸) */
/** 3. 发送短信验证码(设备签名认证由拦截器注入,防短信轰炸) */
@POST("api/device/sms/send")
Single<ApiResponse<MessageResponse>> sendSms(@Body SmsRequest body);
/** 4. 上传加密照片(信封加密,DEK 明文经 HTTPS 传输,设备端无登录 */
/** 4. 上传加密照片(信封加密,设备签名认证由拦截器注入 */
@POST("api/photo/upload")
Single<ApiResponse<UploadPhotoResponse>> uploadPhoto(@Body UploadPhotoRequest body);
/** 5. 恢复设备(零信任:设备签名认证,短信验证 + 新公钥,换机/恢复出厂后调用) */
/** 5. 恢复设备(设备签名认证由拦截器注入,短信验证 + 新公钥,换机/恢复出厂后调用) */
@POST("api/device/recover")
Single<ApiResponse<RecoverResponse>> recoverDevice(@Body RecoverRequest body);
@@ -73,12 +69,11 @@ public interface DeviceApi {
Single<ApiResponse<PhotoRecoverResponse>> recoverPhotos(@Body PhotoRecoverRequest body);
/**
* 6b. 设备本地下载解密单张照片(端到端加密,设备端 Challenge-Response 签名认证)。
* 6b. 设备本地下载解密单张照片(端到端加密,设备签名认证由拦截器注入)。
* 服务端下发「密文 + 设备公钥加密的 DEK + IV」设备在 TEE 内本地解密,服务端不接触明文。
*/
@POST("api/device/photo/{photoId}/local")
Single<ApiResponse<DevicePhotoResponse>> deviceDownloadLocal(@Path("photoId") String photoId,
@Body DeviceStatusRequest body);
Single<ApiResponse<DevicePhotoResponse>> deviceDownloadLocal(@Path("photoId") String photoId);
/** 7. 用户下载并解密照片Web 用户端Bearer Token 鉴权,返回明文照片 Base64 的 JSON */
@GET("api/photo/{photoId}/decrypt")
@@ -93,19 +88,15 @@ public interface DeviceApi {
@GET("api/photo/{photoId}/decrypt/stream")
Single<ResponseBody> downloadAndDecryptStreaming(@Path("photoId") String photoId);
/** 7b. 设备照片 ID 列表(零信任:设备签名认证,返回该设备绑定用户的照片列表) */
/** 7b. 设备照片 ID 列表(设备签名认证由拦截器注入,返回该设备绑定用户的照片列表) */
@POST("api/device/photos")
Single<ApiResponse<UserPhotosResponse>> devicePhotos(@Body DeviceStatusRequest body);
Single<ApiResponse<UserPhotosResponse>> devicePhotos();
/** 8. 设备照片元数据列表(零信任:设备签名认证,含上传时间/来源设备/是否可设备端解密,供展示用 */
/** 8. 设备照片元数据列表(设备签名认证由拦截器注入,含上传时间/来源设备/是否可设备端解密) */
@POST("api/device/photos/metadata")
Single<ApiResponse<PhotoMetadataResponse>> devicePhotosMetadata(@Body DeviceStatusRequest body);
Single<ApiResponse<PhotoMetadataResponse>> devicePhotosMetadata();
/** 9. 获取服务端传输公钥(设备端用它加密上传的 DEK使 DEK 在网络上不明文 */
@GET("api/device/transport-key")
Single<ApiResponse<Map<String, String>>> transportPublicKey();
/** 8. 健康检查(公开) */
/** 9. 健康检查(公开 */
@GET("api/health")
Single<ApiResponse<HealthResponse>> health();
}

View File

@@ -3,10 +3,10 @@ package com.secure.demo.network.model;
import com.google.gson.annotations.SerializedName;
/**
* 绑定设备请求(零信任:设备签名认证)。
* { sn, challenge, signature, userId, phone }
* 绑定设备请求(设备签名认证由 {@code AuthInterceptor} 注入 Header请求体只含业务字段)。
* { sn, userId, phone }
*
* - sn / challenge / signatureChallenge-Response 设备签名认证,服务端用该 SN 对应设备公钥验签
* - sn:目标设备序列号
* - userId绑定目标用户
* - phone可选未传由服务端使用默认值
*/
@@ -15,29 +15,19 @@ public class BindRequest {
@SerializedName("sn")
private String sn;
@SerializedName("challenge")
private String challenge;
@SerializedName("signature")
private String signature;
@SerializedName("userId")
private String userId;
@SerializedName("phone")
private String phone;
public BindRequest(String sn, String challenge, String signature, String userId, String phone) {
public BindRequest(String sn, String userId, String phone) {
this.sn = sn;
this.challenge = challenge;
this.signature = signature;
this.userId = userId;
this.phone = phone;
}
public String getSn() { return sn; }
public String getChallenge() { return challenge; }
public String getSignature() { return signature; }
public String getUserId() { return userId; }
public String getPhone() { return phone; }
}

View File

@@ -1,21 +0,0 @@
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; }
}

View File

@@ -1,34 +0,0 @@
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; }
}

View File

@@ -3,10 +3,10 @@ package com.secure.demo.network.model;
import com.google.gson.annotations.SerializedName;
/**
* 恢复设备请求(恢复出厂/换机后重新授权,零信任:设备签名认证)。
* { sn, challenge, signature, smsCode, newPublicKeyBase64 }
* 恢复设备请求(恢复出厂/换机后重新授权,设备签名认证由 {@code AuthInterceptor} 注入 Header)。
* { sn, smsCode, newPublicKeyBase64 }
*
* - sn / challenge / signatureChallenge-Response 设备签名认证,服务端验签确认设备身份
* - sn:目标设备序列号
* - smsCode短信验证码恢复授权关键闸门
* - newPublicKeyBase64设备新的 TEE 公钥,服务端用其包裹后续要下发的 DEK
*/
@@ -15,29 +15,19 @@ public class RecoverRequest {
@SerializedName("sn")
private String sn;
@SerializedName("challenge")
private String challenge;
@SerializedName("signature")
private String signature;
@SerializedName("smsCode")
private String smsCode;
@SerializedName("newPublicKeyBase64")
private String newPublicKeyBase64;
public RecoverRequest(String sn, String challenge, String signature, String smsCode, String newPublicKeyBase64) {
public RecoverRequest(String sn, String smsCode, String newPublicKeyBase64) {
this.sn = sn;
this.challenge = challenge;
this.signature = signature;
this.smsCode = smsCode;
this.newPublicKeyBase64 = newPublicKeyBase64;
}
public String getSn() { return sn; }
public String getChallenge() { return challenge; }
public String getSignature() { return signature; }
public String getSmsCode() { return smsCode; }
public String getNewPublicKeyBase64() { return newPublicKeyBase64; }
}

View File

@@ -3,35 +3,19 @@ package com.secure.demo.network.model;
import com.google.gson.annotations.SerializedName;
/**
* 发送短信验证码请求(零信任:设备签名认证)。
* { sn, challenge, signature, phone }
* 发送短信验证码请求(设备签名认证由 {@code AuthInterceptor} 注入 Header请求体只含业务字段)。
* { phone }
*
* - sn / challenge / signatureChallenge-Response 设备签名认证,服务端验签确认设备身份后防轰炸
* - phone接收验证码的手机号
*/
public class SmsRequest {
@SerializedName("sn")
private String sn;
@SerializedName("challenge")
private String challenge;
@SerializedName("signature")
private String signature;
@SerializedName("phone")
private String phone;
public SmsRequest(String sn, String challenge, String signature, String phone) {
this.sn = sn;
this.challenge = challenge;
this.signature = signature;
public SmsRequest(String phone) {
this.phone = phone;
}
public String getSn() { return sn; }
public String getChallenge() { return challenge; }
public String getSignature() { return signature; }
public String getPhone() { return phone; }
}

View File

@@ -3,13 +3,13 @@ package com.secure.demo.network.model;
import com.google.gson.annotations.SerializedName;
/**
* 上传加密照片请求(信封加密 + 传输层 DEK 加密)。
* { sn, photoId, ciphertextBase64, ivBase64, encryptedDekBase64, metadataSignature, metadata }
* 上传加密照片请求(信封加密,设备签名认证由 {@code AuthInterceptor} 注入 Header)。
* { sn, photoId, ciphertextBase64, ivBase64, dekBase64, metadataSignature, metadata }
*
* 安全要点:
* - encryptedDekBase64DEK 用「服务端传输公钥」RSA-OAEP 加密后的密文(不再上传明文 DEK
* 服务端用其私有传输密钥解出 DEK 后,再用用户 UK 信封加密存储
* - 即使传输层被中间人截获,也无法还原 DEK、无法解密照片
* - dekBase64设备端随机生成的 AES-256 DEK明文随包上传
* 生产环境应启用 HTTPS或恢复「服务端传输公钥」加密 DEK 的纵深防御(见历史版本 transport-key
* - metadataSignature设备 TEE 私钥对 metadata 的 RSA-PSS 签名,服务端用设备公钥验签防伪
*/
public class UploadPhotoRequest {
@@ -25,8 +25,8 @@ public class UploadPhotoRequest {
@SerializedName("ivBase64")
private final String ivBase64;
@SerializedName("encryptedDekBase64")
private final String encryptedDekBase64;
@SerializedName("dekBase64")
private final String dekBase64;
@SerializedName("metadataSignature")
private final String metadataSignature;
@@ -35,13 +35,13 @@ public class UploadPhotoRequest {
private final String metadata;
public UploadPhotoRequest(String sn, String photoId, String ciphertextBase64,
String ivBase64, String encryptedDekBase64,
String ivBase64, String dekBase64,
String metadataSignature, String metadata) {
this.sn = sn;
this.photoId = photoId;
this.ciphertextBase64 = ciphertextBase64;
this.ivBase64 = ivBase64;
this.encryptedDekBase64 = encryptedDekBase64;
this.dekBase64 = dekBase64;
this.metadataSignature = metadataSignature;
this.metadata = metadata;
}
@@ -50,7 +50,7 @@ public class UploadPhotoRequest {
public String getPhotoId() { return photoId; }
public String getCiphertextBase64() { return ciphertextBase64; }
public String getIvBase64() { return ivBase64; }
public String getEncryptedDekBase64() { return encryptedDekBase64; }
public String getDekBase64() { return dekBase64; }
public String getMetadataSignature() { return metadataSignature; }
public String getMetadata() { return metadata; }
}

View File

@@ -265,41 +265,6 @@ public class DeviceCrypto {
}
}
// ==================== 传输层 DEK 加密(服务端公钥) ====================
/**
* 用「服务端传输公钥」RSA-OAEP 加密 DEK使 DEK 在网络上永不明文(纵深防御)。
*
* 设备上传照片时,不再把明文 DEK 随包上传;而是先用服务端下发的传输公钥加密 DEK
* 上传 {@code encryptedDekBase64}。服务端用其持有的传输私钥解出 DEK 后,再交由 UK 信封加密存储。
* 即使传输层被中间人截获,攻击者也无法还原 DEK、进而无法解密照片。
*
* @param dekBytes 明文 DEKAES-256 原始字节32 字节)
* @param serverPublicKeyB64 服务端传输公钥Base64来自 GET /api/device/transport-key
* @return 用服务端公钥加密后的 DEK 密文Base64 单段)
*/
public String encryptDekWithTransportKey(byte[] dekBytes, String serverPublicKeyB64) {
try {
byte[] pubEncoded = Base64.getDecoder().decode(serverPublicKeyB64);
KeyFactory kf = KeyFactory.getInstance("RSA");
PublicKey serverPub = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(pubEncoded));
Cipher cipher = Cipher.getInstance(RSA_TRANSFORM);
// 显式指定 OAEP 参数(消息摘要 SHA-256 + MGF1-SHA256与服务端严格一致
// 消除 Android(Conscrypt) 与 OpenJDK 对 OAEP/MGF1 默认哈希解释不一致导致的解密失败。
javax.crypto.spec.OAEPParameterSpec oaepSpec =
new javax.crypto.spec.OAEPParameterSpec(
"SHA-256", "MGF1",
java.security.spec.MGF1ParameterSpec.SHA256,
javax.crypto.spec.PSource.PSpecified.DEFAULT);
cipher.init(Cipher.ENCRYPT_MODE, serverPub, oaepSpec);
byte[] encrypted = cipher.doFinal(dekBytes);
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new RuntimeException("Encrypt DEK with transport key failed: " + e.getMessage(), e);
}
}
// ==================== 元数据签名 ====================
/**

View File

@@ -0,0 +1,92 @@
package com.secure.demo.config;
import com.secure.demo.common.UnauthorizedException;
import com.secure.demo.service.DeviceBindingService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 设备签名认证拦截器(零信任,方案 A公钥签名 + 时间戳/nonce 自校验 + Header 承载)。
*
* <p>替代旧的「先 GET /challenge 再签名」两段式流程,设备本地生成时间戳与随机 nonce
* 用 TEE 私钥对 {@code METHOD|path|ts|nonce} 签名后放入请求头,服务端在拦截器内
* 统一完成验签,业务接口零额外认证代码、零额外往返。</p>
*
* <p>请求头格式:</p>
* <pre>
* Authorization: Device-Sig sn=SN-001,ts=1730000000000,nonce=abc123,sig=<base64>
* </pre>
*
* <p>验签通过后,认证通过的 {@link com.secure.demo.model.Device} 会被放入 request attribute
* {@link #ATTR_DEVICE},业务接口直接取出使用。</p>
*
* <p>仅对需要设备认证的接口生效({@link #isDevicePath}),注册/登录/健康检查等公开接口由
* {@link WebConfig} 显式排除。</p>
*/
@Component
public class DeviceAuthInterceptor implements HandlerInterceptor {
/** 认证通过后存放 Device 的 request attribute 名 */
public static final String ATTR_DEVICE = "authenticatedDevice";
private static final String HEADER_PREFIX = "Device-Sig ";
private final DeviceBindingService deviceBindingService;
public DeviceAuthInterceptor(DeviceBindingService deviceBindingService) {
this.deviceBindingService = deviceBindingService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith(HEADER_PREFIX)) {
throw new UnauthorizedException("Missing device signature header");
}
// 解析 sn,ts,nonce,sig
String[] parts = header.substring(HEADER_PREFIX.length()).split(",");
String sn = null, ts = null, nonce = null, sig = null;
for (String p : parts) {
String[] kv = p.trim().split("=", 2);
if (kv.length != 2) continue;
switch (kv[0]) {
case "sn": sn = kv[1]; break;
case "ts": ts = kv[1]; break;
case "nonce": nonce = kv[1]; break;
case "sig": sig = kv[1]; break;
default: break;
}
}
if (sn == null || ts == null || nonce == null || sig == null) {
throw new UnauthorizedException("Device signature header missing fields (sn,ts,nonce,sig)");
}
// 构造签名原文METHOD|path|ts|nonce与设备端 AuthInterceptor 完全一致)
String canonical = request.getMethod() + "|" + request.getRequestURI() + "|" + ts + "|" + nonce;
// 统一验签(查库取公钥 + 时效 + nonce 防重放 + RSA-PSS 验签)
com.secure.demo.model.Device device = deviceBindingService.authenticateByHeader(
sn, ts, nonce, sig, canonical);
// 认证通过:把设备放入 request attribute供业务接口使用
request.setAttribute(ATTR_DEVICE, device);
return true;
}
/**
* 判断某路径是否需要设备签名认证(供 WebConfig 注册时按需放行)。
* 公开接口(注册/登录/健康检查/传输公钥已移除)不需要拦截。
*/
public static boolean isDevicePath(String path) {
return path.startsWith("/api/device/")
|| path.startsWith("/api/photo/");
}
/** 便捷方法:是否 GET无 body签名原文不含 bodyHash 的占位) */
public static boolean isGet(HttpServletRequest request) {
return HttpMethod.GET.matches(request.getMethod());
}
}

View File

@@ -3,6 +3,7 @@ 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.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
@@ -40,10 +41,12 @@ public class WebConfig implements WebMvcConfigurer {
+ "http://localhost:5173,http://127.0.0.1:5173";
private final List<String> allowedOrigins;
private final DeviceAuthInterceptor deviceAuthInterceptor;
public WebConfig(
@Value("${app.cors.allowed-origins:}") String configuredOrigins,
@Value("${APP_CORS_ALLOWED_ORIGINS:}") String envOrigins) {
@Value("${APP_CORS_ALLOWED_ORIGINS:}") String envOrigins,
DeviceAuthInterceptor deviceAuthInterceptor) {
String raw = (envOrigins != null && !envOrigins.isBlank()) ? envOrigins : configuredOrigins;
if (raw == null || raw.isBlank()) {
raw = DEFAULT_ALLOWED_ORIGINS;
@@ -52,6 +55,20 @@ public class WebConfig implements WebMvcConfigurer {
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
this.deviceAuthInterceptor = deviceAuthInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 设备签名认证拦截器:仅拦截需要设备认证的路径(/api/device/**、/api/photo/**
// 显式排除公开接口注册、登录、健康检查、CORS 预检)。
registry.addInterceptor(deviceAuthInterceptor)
.addPathPatterns("/api/device/**", "/api/photo/**")
.excludePathPatterns(
"/api/device/register", // 注册:设备未入库,用请求内公钥做 PoP 验签
"/api/photo/{photoId}/decrypt", // Web 用户端 Bearer 鉴权,非设备签名
"/api/photo/{photoId}/decrypt/stream"
);
}
@Override

View File

@@ -4,17 +4,15 @@ import com.secure.demo.auth.TokenService;
import com.secure.demo.common.ApiException;
import com.secure.demo.common.ApiResponse;
import com.secure.demo.common.UnauthorizedException;
import com.secure.demo.config.DeviceAuthInterceptor;
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;
@@ -39,34 +37,39 @@ import java.util.Map;
import java.util.UUID;
/**
* 设备安全 API 控制器(前后端分离版
* 设备安全 API 控制器(前后端分离版,方案 AHeader 设备签名认证,零 challenge 往返)。
*
* ┌────────────────────────────────────────────────────────────────┐
* │ 接口分组(模拟真实场景) │
* ├────────────────────────────────────────────────────────────────┤
* │ 公开: │
* │ POST /api/auth/register 用户注册(返回 Token
* │ POST /api/auth/login 用户登录(返回 Token
* │ GET /api/health 健康检查 │
* ├────────────────────────────────────────────────────────────────┤
* │ 设备端(无登录体系,信任边界=TEE 密钥/SN/Recovery Token
* │ POST /api/device/register 设备注册SN + 公钥) │
* │ POST /api/photo/upload 上传加密照片(设备签名) │
* │ POST /api/photo/recover 恢复后获取照片 DEK │
* ├────────────────────────────────────────────────────────────────┤
* │ 用户端(需要 Authorization: Bearer <token>
* │ POST /api/device/bind 绑定设备SN
* │ POST /api/device/sms/send 发送短信验证码 │
* │ POST /api/device/recover 恢复授权(短信验证) │
* │ GET /api/photo/{id}/decrypt 下载并解密照片 │
* │ GET /api/user/photos 我的照片列表(新增) │
* │ GET /api/user/devices 我的设备列表(新增) │
* └────────────────────────────────────────────────────────────────┘
* <p>设备签名认证改为「请求头 + 时间戳/nonce 自校验」:设备用 TEE 私钥对
* {@code METHOD|path|ts|nonce} 签名放入 {@code Authorization: Device-Sig ...} 头,
* 由 {@link DeviceAuthInterceptor} 统一验签后把设备放入 request attribute。</p>
*
* 鉴权优先级用户端接口Bearer Token > X-User-Id HeaderAndroid
* demo 兼容)> body.userId集成测试兼容。生产环境只保留 Bearer Token。
* <p>接口分组:</p>
* <pre>
* 公开(无需设备认证):
* POST /api/auth/register 用户注册
* POST /api/auth/login 用户登录
* POST /api/auth/logout 登出
* POST /api/device/register 设备注册PoP请求内公钥验签公开
* GET /api/health 健康检查
*
* 所有响应统一为 ApiResponse{code, message, data},前端只依赖该契约。
* 设备端(拦截器自动认证,无需业务层重复验签):
* POST /api/device/status 查询注册/绑定状态App 启动复用)
* POST /api/device/bind 绑定设备
* POST /api/device/sms/send 发送短信验证码
* POST /api/device/recover 恢复授权(短信 + SN 双因子)
* POST /api/device/photos 本设备绑定用户的照片列表
* POST /api/device/photos/metadata 本设备绑定用户的照片元数据
* POST /api/device/photo/{id}/local 设备本地下载解密单张照片
* POST /api/photo/upload 上传加密照片
* POST /api/photo/recover 恢复后获取照片 DEK
*
* 用户端(需要 Authorization: Bearer &lt;token&gt;
* GET /api/photo/{id}/decrypt 下载并解密照片
* GET /api/photo/{id}/decrypt/stream 下载并解密照片(流式)
* GET /api/user/photos 我的照片列表
* GET /api/user/photos/metadata 我的照片元数据
* GET /api/user/devices 我的设备列表
* </pre>
*/
@RestController
@RequestMapping("/api")
@@ -78,7 +81,6 @@ public class DeviceController {
private final DeviceBindingService deviceBindingService;
private final EncryptedPhotoRepository photoRepository;
private final TokenService tokenService;
private final TransportKeyService transportKeyService;
// 密文文件落盘根目录(来自配置 app.upload.dir默认 ./uploads
private final Path uploadRoot;
@@ -90,14 +92,12 @@ public class DeviceController {
DeviceBindingService deviceBindingService,
EncryptedPhotoRepository photoRepository,
TokenService tokenService,
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);
// 启动时确保上传目录存在
@@ -115,25 +115,16 @@ public class DeviceController {
return ApiResponse.ok(Map.of("status", "ok", "service", "secure-device-demo"));
}
// ==================== 1. 设备注册(设备端,无登录PoP 验签) ====================
// ==================== 1. 设备注册(公开PoP 验签) ====================
/**
* 设备首次启动 / 恢复出厂后调用
* 设备首次启动 / 恢复出厂后调用
*
* 安全要求(修复 P0-1注册必须携带 PoP 证明,防止攻击者用自己公钥冒名注册受害者 SN。
* 流程:
* 1. 设备先 GET /api/device/challenge 获取一次性挑战值
* 2. 设备用 TEE 私钥对 challenge 签名
* 3. 本接口用「请求内上传的公钥」验签Proof of Possession证明私钥持有者确实拥有该公钥
* 4. 验签通过后才允许注册/停旧换新
* <p>注册接口保持公开(设备尚未入库),用请求内携带的公钥做 PoP 验签:
* 设备先本地生成 {@code challenge = ts:nonce},用 TEE 私钥签名后上报,
* 服务端用「请求内上传的公钥」验签,证明私钥持有者确实拥有该公钥,防止冒名注册。</p>
*
* Request: {
* "sn": "SN-DEMO-001",
* "publicKeyBase64": "...",
* "challenge": "1730000000000:nonce123",
* "signature": "..."
* }
* Response: data = { "deviceId": "...", "sn": "SN-DEMO-001", "message": "..." }
* Request: { "sn", "publicKeyBase64", "challenge": "ts:nonce", "signature" }
*/
@PostMapping("/device/register")
public ApiResponse<Map<String, String>> registerDevice(@RequestBody Map<String, String> req) {
@@ -157,85 +148,51 @@ 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);
}
// ==================== 2. 设备状态查询(拦截器认证) ====================
/**
* 查询设备注册/绑定状态(设备端,无登录,App 启动时调用以复用已有状态)。
* 本接口要求设备签名认证Challenge-Response基于 TEE 私钥)。
* 查询设备注册/绑定状态App 启动时调用以复用已有状态)。设备认证由拦截器完成。
*
* 认证流程:
* 1. 设备先 GET /api/device/challenge 获取挑战值
* 2. 设备用 TEE 私钥对 challenge 签名
* 3. 本接口验签通过后才返回状态(认证失败前不泄露任何绑定信息)
*
* Request: POST /api/device/status
* body = { "sn": "...", "challenge": "...", "signature": "..." }
* Response: data = {
* "registered": true, // 该 SN 是否已注册
* "bound": true, // 是否已绑定用户
* "active": true, // 是否激活
* "deviceId": "...", // 已注册时返回
* "userId": "...", // 已绑定时返回
* "publicKeyBase64": "..." // 已注册时返回(供 App 判断公钥是否轮换)
* }
* Response: data = { registered, bound, active, deviceId, userId, publicKeyBase64 }
*/
@PostMapping("/device/status")
public ApiResponse<Map<String, Object>> deviceStatus(@RequestBody StatusChallengeRequest req) {
if (req.getSn() == null || req.getSn().isBlank()) {
throw new IllegalArgumentException("sn required");
}
// 设备签名认证(时效 + 防重放 + 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();
public ApiResponse<Map<String, Object>> deviceStatus(HttpServletRequest httpRequest) {
Device device = authenticatedDevice(httpRequest);
boolean registered = true;
boolean bound = device.getUserId() != null && !device.getUserId().isBlank();
boolean active = device.isActive();
return ApiResponse.ok(Map.of(
"registered", registered,
"bound", bound,
"active", active,
"deviceId", registered ? device.getDeviceId() : "",
"deviceId", device.getDeviceId(),
"userId", bound ? device.getUserId() : "",
"publicKeyBase64", registered ? device.getPublicKeyBase64() : ""
"publicKeyBase64", device.getPublicKeyBase64()
));
}
// ==================== 2. 绑定设备(设备签名认证 ====================
// ==================== 3. 绑定设备(拦截器认证 / Bearer ====================
/**
* 绑定设备(零信任:设备签名认证 + 归属校验)。
* 绑定设备(零信任)。
*
* 认证方式(二选一)
* - Android 设备端:请求体携带 { sn, challenge, signature, userId },服务端用该 SN 对应
* 设备公钥验签Challenge-Response证明请求方持有该设备 TEE 私钥;绑定目标 userId 取自 body
* - Web 用户端Authorization: Bearer <token>,绑定目标为 Token 对应用户。
* <p>认证方式:</p>
* <ul>
* <li>Android 设备端:请求头 Device-Sig拦截器认证绑定目标 userId 取自 body</li>
* <li>Web 用户端Authorization: Bearer &lt;token&gt;,绑定目标为 Token 对应用户。</li>
* </ul>
*
* 不再信任 X-User-Id / body.userId 自报身份(零信任)。
* Request: { "sn": "...", "challenge": "...", "signature": "...", "userId": "user-001", "phone": "..." }
* Response: data = { "message": "..." }
* Request: { "userId": "user-001", "phone": "..." }
*/
@PostMapping("/device/bind")
public ApiResponse<Map<String, String>> bindDevice(@RequestBody Map<String, String> req,
HttpServletRequest httpRequest) {
String userId;
Device device;
// 1) Web 用户端Bearer Token
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");
@@ -249,8 +206,8 @@ public class DeviceController {
throw new ApiException(404, "Device not registered for SN: " + sn);
}
} else {
// 2) Android 设备端:设备签名认证SN + challenge + signature
device = authenticateDeviceBySignature(req);
// Android 设备端:拦截器已完成设备签名认证
device = authenticatedDevice(httpRequest);
userId = req.get("userId");
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("userId required for device-signature bind");
@@ -267,21 +224,17 @@ public class DeviceController {
return ApiResponse.ok(Map.of("message", "Device bound to user successfully"));
}
// ==================== 3. 短信验证码(设备签名认证) ====================
// ==================== 4. 发送短信验证码(拦截器认证) ====================
/**
* 发送短信验证码(设备签名认证,防短信轰炸)。
* 发送短信验证码(拦截器认证设备身份,防短信轰炸)。
*
* Android 设备端携带 { sn, challenge, signature, phone },服务端验签确认设备身份后,
* 校验该设备已绑定用户,再发送短信。不再依赖 X-User-Id 自报身份。
* Request: { "sn": "...", "challenge": "...", "signature": "...", "phone": "13800138000" }
* Response: data = { "message": "..." }
* Request: { "phone": "13800138000" }
*/
@PostMapping("/device/sms/send")
public ApiResponse<Map<String, String>> sendSms(@RequestBody Map<String, String> req,
HttpServletRequest httpRequest) {
// 设备签名认证(防轰炸 + 设备身份校验)
Device device = authenticateDeviceBySignature(req);
Device device = authenticatedDevice(httpRequest);
if (device.getUserId() == null || device.getUserId().isBlank()) {
throw new SecurityException("Device not bound to any user");
}
@@ -293,19 +246,16 @@ public class DeviceController {
return ApiResponse.ok(Map.of("message", "SMS code sent (check server logs for demo code)"));
}
// ==================== 6c. 设备照片列表(设备签名认证) ====================
// ==================== 5. 设备照片列表 / 元数据(拦截器认证) ====================
/**
* 设备端拉取「本设备绑定用户」的照片 ID 列表(零信任,设备签名认证)。
* 设备端拉取「本设备绑定用户」的照片 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);
public ApiResponse<Map<String, Object>> devicePhotos(HttpServletRequest httpRequest) {
Device device = authenticatedDevice(httpRequest);
String userId = device.getUserId();
if (userId == null || userId.isBlank()) {
throw new SecurityException("Device not bound to any user");
@@ -315,21 +265,22 @@ public class DeviceController {
}
/**
* 设备照片元数据列表(零信任:设备签名认证,供 Android 展示)。
* 设备照片元数据列表(拦截器认证,供 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);
public ApiResponse<Map<String, Object>> devicePhotosMetadata(HttpServletRequest httpRequest) {
Device device = authenticatedDevice(httpRequest);
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<>();
// 恢复出厂+重新注册后,历史照片记录的 deviceId 是已停用的旧设备,但仍可被
// 新设备active=true已恢复授权解密。因此「是否可解密」按该用户是否存在
// active 设备判断,而非照片上传时那个 deviceId 是否仍 active。
boolean userHasActiveDevice = deviceBindingService.hasActiveDeviceForUser(userId);
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) continue;
@@ -339,61 +290,37 @@ public class DeviceController {
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());
item.put("activeDevice", userHasActiveDevice);
list.add(item);
}
return ApiResponse.ok(Map.of("photos", list));
}
// ==================== 3b. 获取服务端传输公钥(设备上传 DEK 加密用 ====================
// ==================== 6. 上传加密照片(拦截器认证 ====================
/**
* 下发服务端传输公钥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. 上传加密照片(设备端,无登录) ====================
/**
* 设备上传加密照片(信封加密,设备私钥签名防伪)
* <p>DEK 由设备端随机生成并随包上传明文demo 无 HTTPS 时为纵深防御的取舍,
* 生产环境应启用 HTTPS 或恢复传输公钥加密)。</p>
*
* Request:
* {
* "sn": "SN-DEMO-001",
* "photoId": "photo-001",
* "ciphertextBase64": "...",
* "ivBase64": "...",
* "encryptedDekBase64": "...", <-- 传输公钥加密的 DEK不再上传明文 DEK
* "metadataSignature": "...", <-- 设备私钥签名
* "metadata": "SN-DEMO-001|ts|photo-001"
* }
*
* 安全限制:密文 Base64 换算后不得超过 app.upload.max-size-mb默认 20MB
* 并对 ivBase64 / encryptedDekBase64 / metadataSignature / metadata 限长,防 DoS。
* Request: { "sn", "photoId", "ciphertextBase64", "ivBase64", "dekBase64", "metadataSignature", "metadata" }
*/
@PostMapping("/photo/upload")
public ApiResponse<Map<String, String>> uploadPhoto(@RequestBody Map<String, String> req) {
String sn = req.get("sn");
public ApiResponse<Map<String, String>> uploadPhoto(@RequestBody Map<String, String> req,
HttpServletRequest httpRequest) {
Device device = authenticatedDevice(httpRequest);
String sn = device.getSn();
// photoId 净化(仅允许字母/数字/下划线/连字符),非法值回退 UUID杜绝路径穿越
String photoId = sanitizePhotoId(req.getOrDefault("photoId", UUID.randomUUID().toString().replace("-", "")));
String ciphertextBase64 = req.get("ciphertextBase64");
String ivBase64 = req.get("ivBase64");
// DEK 不再明文传输:设备用「服务端传输公钥加密 DEK 后上传 encryptedDekBase64
String encryptedDekBase64 = req.get("encryptedDekBase64");
// DEK:设备端随机生成,明文随包上传(生产需 HTTPS / 传输公钥加密
String dekBase64 = req.get("dekBase64");
String metadataSignature = req.get("metadataSignature");
String metadata = req.get("metadata");
// 0. 输入大小限制(防 DoS
// - 密文 Base64 长度换算回字节后不得超过 maxUploadBytes
// - 其他元数据字段也限长,防止超大 body 消耗内存/存储
// 0. 输入大小限制(防 DoS
if (ciphertextBase64 == null || ciphertextBase64.isBlank()) {
throw new IllegalArgumentException("ciphertextBase64 required");
}
@@ -405,9 +332,8 @@ public class DeviceController {
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 (dekBase64 == null || dekBase64.isBlank() || dekBase64.length() > 1024) {
throw new IllegalArgumentException("dekBase64 missing or too long");
}
if (metadataSignature != null && metadataSignature.length() > 1024) {
throw new IllegalArgumentException("metadataSignature too long");
@@ -416,11 +342,7 @@ public class DeviceController {
throw new IllegalArgumentException("metadata too long");
}
// 1. 查找设备
Device device = deviceBindingService.findDeviceBySn(sn);
if (device == null) {
throw new ApiException(404, "Device not registered for SN: " + sn);
}
// 1. 设备状态校验(拦截器已认证,此处再校验归属)
if (!device.isActive()) {
throw new SecurityException("Device not active. Please complete recovery.");
}
@@ -429,11 +351,10 @@ public class DeviceController {
throw new SecurityException("Device not bound to any user");
}
// 2. 验签:用设备公钥验证 metadataSignatureSHA256withRSA),防伪/防篡改
// 2. 验签:用设备公钥验证 metadataSignatureRSA-PSS),防伪/防篡改
if (metadata == null || metadataSignature == null || metadataSignature.isEmpty()) {
throw new IllegalArgumentException("metadata and metadataSignature required");
}
// 元数据必须以设备 SN 开头,防止跨设备重放他人签名
if (!metadata.startsWith(sn + "|")) {
throw new SecurityException("metadata must be bound to this device SN");
}
@@ -442,14 +363,7 @@ public class DeviceController {
throw new SecurityException("Metadata signature verification failed");
}
// 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);
// 3. 用 UK 加密 DEK 存储(服务端永远只存 "UK 加密后的 DEK"
String encryptedDek = keyManagementService.wrapDEK(dekBase64, userId);
// 4. 密文落盘
@@ -476,19 +390,12 @@ public class DeviceController {
));
}
// ==================== 5. 恢复授权(设备签名认证 ====================
// ==================== 7. 恢复授权(拦截器认证 / Bearer ====================
/**
* 恢复出厂后重新绑定 + 授权(零信任:设备签名认证)。
* 恢复出厂后重新绑定 + 授权(短信 + SN 双因子)。
*
* 认证方式:
* - 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": "..." }
* Request: { "sn", "smsCode", "newPublicKeyBase64" }
*/
@PostMapping("/device/recover")
public ApiResponse<Map<String, String>> recoverDevice(@RequestBody Map<String, String> req,
@@ -504,8 +411,8 @@ public class DeviceController {
}
sn = req.get("sn");
} else {
// Android 设备端:设备签名认证
Device device = authenticateDeviceBySignature(req);
// Android 设备端:拦截器已完成设备签名认证
Device device = authenticatedDevice(httpRequest);
sn = device.getSn();
userId = device.getUserId();
}
@@ -529,17 +436,17 @@ public class DeviceController {
));
}
// ==================== 6. 恢复后获取照片 DEK设备端,无登录 ====================
// ==================== 8. 恢复后获取照片 DEK拦截器认证 ====================
/**
* 设备用 Recovery Token 获取该用户所有照片的 DEK
* 服务端用设备当前公钥逐一加密 DEK 后下发
* 设备用 Recovery Token 获取该用户所有照片的 DEK,服务端用设备当前公钥逐一加密下发。
*
* Request: { "deviceId": "...", "recoveryToken": "...", "userId": "user-001" }
* Response: data = { "deviceId": "...", "photoCount": 3, "deks": [{"photoId","encryptedDekBase64"}] }
* Request: { "deviceId", "recoveryToken", "userId" }
*/
@PostMapping("/photo/recover")
public ApiResponse<Map<String, Object>> recoverPhotos(@RequestBody Map<String, String> req) {
public ApiResponse<Map<String, Object>> recoverPhotos(@RequestBody Map<String, String> req,
HttpServletRequest httpRequest) {
Device authenticated = authenticatedDevice(httpRequest);
String deviceId = req.get("deviceId");
String recoveryToken = req.get("recoveryToken");
String userId = req.get("userId");
@@ -547,17 +454,19 @@ public class DeviceController {
throw new IllegalArgumentException("deviceId, recoveryToken, userId all required");
}
// 1. 验证设备
// 1. 验证设备(必须与拦截器认证设备一致)
Device device = deviceBindingService.getDevice(deviceId);
if (device == null || !device.isActive()) {
throw new SecurityException("Device not active");
}
if (!deviceId.equals(authenticated.getDeviceId())) {
throw new SecurityException("deviceId does not match authenticated device");
}
if (!userId.equals(device.getUserId())) {
throw new SecurityException("Token does not match device owner");
}
// 2. 验证 recoveryTokendeviceId 匹配 + 5 分钟时间窗口 + nonce 一次性防重放
// Token 明文格式: userId|deviceId|timestamp|nonce设备用 TEE 私钥解密后回传)
if (!deviceBindingService.validateRecoveryToken(deviceId, recoveryToken)) {
throw new SecurityException("Invalid, expired or replayed recovery token");
}
@@ -571,13 +480,9 @@ public class DeviceController {
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) continue;
// 用 UK 解出明文 DEK
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), userId);
// 用设备公钥加密 DEK设备私钥才能解
String encryptedDek = RsaUtil.encryptBase64(dek.getEncoded(), devicePubKey);
// 密文落盘于 filePath读文件 -> Base64供设备端在本地用 DEK 还原内容
String ciphertextBase64;
try {
ciphertextBase64 = Base64.getEncoder()
@@ -602,49 +507,29 @@ public class DeviceController {
));
}
// ==================== 6b. 设备本地下载解密单张照片(设备端Challenge-Response 签名认证) ====================
// ==================== 8b. 设备本地下载解密单张照片(拦截器认证) ====================
/**
* 设备端本地解密:服务端仅下发「密文 + 设备公钥加密的 DEK + IV」设备在 TEE 内用私钥
* 设备端本地解密:服务端仅下发「密文 + 设备公钥加密的 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" }
* Request: { "sn" }(可选,主要从拦截器认证取设备)+ path photoId
*/
@PostMapping("/device/photo/{photoId}/local")
public ApiResponse<Map<String, String>> deviceDownloadLocal(
@PathVariable String photoId,
@RequestBody DeviceLocalPhotoRequest req) {
HttpServletRequest httpRequest) {
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)");
}
// 设备认证由拦截器完成
Device device = authenticatedDevice(httpRequest);
// 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");
}
@@ -652,7 +537,7 @@ public class DeviceController {
throw new SecurityException("Photo does not belong to this device's user");
}
// 4. 用 UK 解出 DEK → 用设备公钥加密 DEK → 连同密文 + IV 下发(设备本地解密)
// 用 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);
@@ -674,18 +559,12 @@ public class DeviceController {
));
}
// ==================== 7. 下载解密照片(用户端,登录) ====================
// ==================== 9. 下载解密照片(用户端,登录) ====================
/**
* 用户端(已登录)下载并解密照片
*
* 流程:鉴权(Bearer Token / X-User-Id) → 归属检查 → UK 解 DEK → DEK 解照片
* Response: data = { "photoId": "...", "plaintextBase64": "..." }
*/
@GetMapping("/photo/{photoId}/decrypt")
public ApiResponse<Map<String, String>> downloadAndDecrypt(@PathVariable String photoId,
HttpServletRequest httpRequest) {
String userId = resolveUserId(httpRequest, null);
String userId = resolveUserId(httpRequest);
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) {
throw new ApiException(404, "Photo not found: " + photoId);
@@ -695,49 +574,30 @@ public class DeviceController {
}
try {
// 从文件读取密文并转 Base64 字符串
String ciphertextBase64 = readCiphertextFromFile(photo.getFilePath());
// 1. UK 解密 DEK
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), userId);
// 2. DEK 解密照片
byte[] plaintext = AesGcmUtil.decrypt(
ciphertextBase64,
photo.getIvBase64(),
dek
);
// 3. 返回明文JSON Base64生产环境可用 StreamingResponseBody 流式传输)
return ApiResponse.ok(Map.of(
"photoId", photoId,
"plaintextBase64", Base64.getEncoder().encodeToString(plaintext)
));
} catch (Exception e) {
log.error("Decryption failed for photo {}", photoId, e);
throw new ApiException(500, "Decryption failed");
}
}
// ==================== 7b. 下载解密照片(流式,用户端,登录) ====================
// ==================== 9b. 下载解密照片(流式,用户端,登录) ====================
/**
* 用户端(已登录)下载并解密照片 —— 流式传输版。
*
* 与 {@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);
String userId = resolveUserId(httpRequest);
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) {
throw new ApiException(404, "Photo not found: " + photoId);
@@ -746,11 +606,8 @@ public class DeviceController {
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()
@@ -763,14 +620,10 @@ public class DeviceController {
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");
}
@@ -783,32 +636,22 @@ public class DeviceController {
.body(stream);
}
// ==================== 8. 我的照片列表(用户端,登录) ====================
// ==================== 10. 我的照片/设备列表(用户端,登录) ====================
/**
* 当前登录用户的照片列表(仅 photoId不含密文/DEK
*
* Response: data = { "photos": ["photo-001", "photo-002", ...] }
*/
@GetMapping("/user/photos")
public ApiResponse<Map<String, Object>> getUserPhotos(HttpServletRequest httpRequest) {
String userId = resolveUserId(httpRequest, null);
String userId = resolveUserId(httpRequest);
List<String> photoIds = deviceBindingService.getUserPhotoIds(userId);
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);
String userId = resolveUserId(httpRequest);
List<Map<String, Object>> list = new ArrayList<>();
// 与 devicePhotosMetadata 同理:恢复后历史照片由用户当前 active 设备解密,
// 因此按「该用户是否存在 active 设备」判断是否可解密。
boolean userHasActiveDevice = deviceBindingService.hasActiveDeviceForUser(userId);
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) continue;
@@ -816,25 +659,17 @@ public class DeviceController {
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());
item.put("activeDevice", userHasActiveDevice);
list.add(item);
}
return ApiResponse.ok(Map.of("photos", list));
}
// ==================== 9. 我的设备列表(用户端,登录) ====================
/**
* 当前登录用户的设备列表
*
* Response: data = { "devices": [ { "deviceId","sn","active","bindTime","lastRecoveryTime" } ] }
*/
@GetMapping("/user/devices")
public ApiResponse<Map<String, Object>> getUserDevices(HttpServletRequest httpRequest) {
String userId = resolveUserId(httpRequest, null);
String userId = resolveUserId(httpRequest);
List<Device> devices = deviceBindingService.getDevicesByUser(userId);
List<Map<String, Object>> list = new ArrayList<>();
@@ -853,19 +688,9 @@ public class DeviceController {
// ==================== 鉴权辅助 ====================
/**
* 解析当前请求的用户 ID
*
* 优先级:
* 1. Authorization: Bearer <token>Web 用户端登录后携带,真实场景唯一方式)
* 2. X-User-Id HeaderAndroid 设备端 demo 兼容AuthInterceptor 自动注入)
* 3. body.userId集成测试 / 旧调用兼容)
*
* 均无 → 401 Unauthorized
* 从请求头解析用户 ID仅允许 Bearer Token
*/
private String resolveUserId(HttpServletRequest request, Map<String, String> body) {
// 仅允许 Bearer TokenWeb 用户端)。
// 零信任原则:不再信任 X-User-Id / body.userId 自报身份(可伪造)。
// Android 设备端一律走 authenticateDeviceBySignature() 设备签名认证。
private String resolveUserId(HttpServletRequest request) {
String auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
String userId = tokenService.getUserId(auth.substring(7).trim());
@@ -878,37 +703,18 @@ public class DeviceController {
}
/**
* 设备签名认证Challenge-Response用请求体中的 { sn, challenge, signature }
* 服务端以该 SN 对应设备公钥验签,证明请求方持有该设备的 TEE 私钥SN ↔ TEE 绑定)。
*
* @return 认证通过后对应的设备
* 从 request attribute 取出拦截器认证通过后的设备。
*/
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);
private Device authenticatedDevice(HttpServletRequest httpRequest) {
Device device = (Device) httpRequest.getAttribute(DeviceAuthInterceptor.ATTR_DEVICE);
if (device == null) {
throw new SecurityException("Device not registered for SN: " + sn);
throw new UnauthorizedException("Device authentication required");
}
return device;
}
// ==================== 文件落盘辅助方法 ====================
/**
* 将 Base64 密文解码后写入上传目录,文件名 = {photoId}.enc
*
* 安全:对 photoId 先做净化(仅允许字母/数字/下划线/连字符),并校验规范化后的
* 文件路径仍位于上传根目录内,杜绝「../」、绝对路径、空字节等路径穿越。
*
* @return 文件绝对路径
*/
private String writeCiphertextToFile(String photoId, String ciphertextBase64) throws IOException {
String safeId = sanitizePhotoId(photoId);
Path target = resolveWithinUploadRoot(safeId + ".enc");
@@ -917,11 +723,6 @@ public class DeviceController {
return target.toAbsolutePath().toString();
}
/**
* 净化照片 ID只允许 [A-Za-z0-9_-],长度 1~64。
* 非法(含 ../、绝对路径、空格、特殊字符等)时回退为随机 UUID
* 既防御路径穿越,又不破坏正常上传流程。
*/
private String sanitizePhotoId(String photoId) {
if (photoId == null || photoId.isBlank()) {
return UUID.randomUUID().toString().replace("-", "");
@@ -933,10 +734,6 @@ public class DeviceController {
return UUID.randomUUID().toString().replace("-", "");
}
/**
* 在 uploadRoot 内解析目标路径(纵深防御):解析后必须仍位于 uploadRoot 之下,
* 否则视为越界拒绝。防止 photoId或其拼接结果通过符号链接 / .. / 绝对路径逃出目录。
*/
private Path resolveWithinUploadRoot(String relativeName) {
Path rootAbs = uploadRoot.toAbsolutePath().normalize();
Path target = rootAbs.resolve(relativeName).normalize();
@@ -946,11 +743,6 @@ public class DeviceController {
return target;
}
/**
* 从落盘文件读取密文并转为 Base64 字符串(供 AesGcmUtil.decrypt 使用)。
*
* 安全:读取前校验文件必须位于上传根目录内(纵深防御,防止存储路径被篡改后读取目录外文件)。
*/
private String readCiphertextFromFile(String filePath) throws IOException {
Path p = Paths.get(filePath).toAbsolutePath().normalize();
Path rootAbs = uploadRoot.toAbsolutePath().normalize();

View File

@@ -1,28 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,189 +0,0 @@
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

@@ -146,6 +146,26 @@ public class DeviceBindingService {
return deviceRepository.findById(deviceId).orElse(null);
}
/**
* 判断用户是否仍持有「可本地解密」的激活设备。
*
* 恢复出厂 + 重新注册后,历史照片记录的 deviceId 仍是已停用的旧设备,
* 但新设备active=true已通过恢复授权接替并仍能解密这些照片。
* 因此判断「照片当前是否可解密」应以「该用户是否存在 active 设备」为准,
* 而非照片记录时那个 deviceId 是否仍 active否则恢复后会被误判为不可解密
*/
public boolean hasActiveDeviceForUser(String userId) {
if (userId == null || userId.isBlank()) {
return false;
}
for (Device d : deviceRepository.findAll()) {
if (userId.equals(d.getUserId()) && d.isActive()) {
return true;
}
}
return false;
}
// ==================== 3. 短信验证码(模拟) ====================
/**
@@ -292,30 +312,18 @@ public class DeviceBindingService {
return result;
}
// ==================== 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();
}
// ==================== 7. 设备注册认证(PoP ====================
/**
* 校验注册请求的 PoP 签名:用<b>请求内上传的公钥</b>验证设备对 challenge 的签名。
*
* <p>与 {@link #verifyStatusChallenge} 的区别:注册时设备可能尚未入库,因此不能
* 按 SN 查库取公钥,而是直接用请求体携带的 {@code publicKeyBase64} 验签。
* 验签通过即证明「该公钥的私钥持有者」发起了本次注册,从而绑定 SN ↔ TEE 私钥。</p>
* <p>注册时设备尚未入库,因此不能按 SN 查库取公钥,而是直接用请求体携带的
* {@code publicKeyBase64} 验签。challenge 由设备本地生成(格式 {@code ts:nonce}
* 用 TEE 私钥签名后上报)。验签通过即证明「该公钥的私钥持有者」发起了本次注册,
* 从而绑定 SN ↔ TEE 私钥,防止攻击者用自己公钥冒名注册受害者 SN。</p>
*
* @param publicKeyBase64 请求上传的设备公钥
* @param challenge 服务端下发的挑战值,格式 <timestampMillis>:<nonce>
* @param challenge 设备本地生成的挑战值,格式 <timestampMillis>:<nonce>
* @param signature 设备用 TEE 私钥对 challenge 的签名Base64
*/
public void verifyRegisterChallenge(String publicKeyBase64, String challenge, String signature) {
@@ -359,57 +367,46 @@ public class DeviceBindingService {
}
}
/**
* 生成一个用于设备状态查询认证的一次性挑战值。
* 格式:<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;
}
// ==================== 8. Header 设备签名认证(方案 A替代 Challenge-Response ====================
/**
* 校验设备对挑战值的签名是否合法
* 通过请求头承载的设备签名完成认证(零额外往返)
*
* 安全逻辑:
* - challenge 必须解析为 <timestamp>:<nonce>,且 timestamp 在有效期内
* - nonce 必须是首次使用(一次性,防重放)
* - 使用设备已注册公钥验签RSA对应设备 TEE 私钥签名)
* <p>设备本地生成时间戳 {@code ts} 与随机 {@code nonce},用 TEE 私钥对
* {@code canonical}METHOD|path|ts|nonce签名本方法</p>
* <ol>
* <li>校验 {@code ts} 在 {@link #CHALLENGE_VALID_WINDOW_MS} 时效窗口内;</li>
* <li>校验 {@code nonce} 一次性(防重放,复用 status nonce 防重放池);</li>
* <li>按 {@code sn} 查库取设备并校验 active</li>
* <li>用设备公钥对 {@code canonical} 验签。</li>
* </ol>
*
* @return true 表示认证通过;否则抛出 SecurityException
* @return 认证通过后的设备
*/
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");
public Device authenticateByHeader(String sn, String ts, String nonce, String sig, String canonical) {
if (sn == null || sn.isBlank() || ts == null || nonce == null
|| nonce.isBlank() || sig == null || sig.isBlank()) {
throw new SecurityException("sn, ts, nonce, sig required");
}
// 1. 解析并校验时效
String[] parts = challenge.split(":", 2);
if (parts.length != 2) {
throw new SecurityException("malformed challenge");
}
// 1. 时效校验
long issuedAt;
try {
issuedAt = Long.parseLong(parts[0]);
issuedAt = Long.parseLong(ts);
} catch (NumberFormatException e) {
throw new SecurityException("malformed challenge timestamp");
throw new SecurityException("malformed ts");
}
long age = System.currentTimeMillis() - issuedAt;
if (age < 0 || age > CHALLENGE_VALID_WINDOW_MS) {
throw new SecurityException("challenge expired");
throw new SecurityException("signature timestamp expired");
}
// 2. 校验 nonce 一次性(防重放)
String nonce = parts[1];
// 2. nonce 一次性(防重放,与注册/状态共用同一防重放池
if (!usedStatusNonces.add(nonce)) {
throw new SecurityException("challenge nonce already used (replay)");
throw new SecurityException("nonce already used (replay)");
}
// 3. 取出设备公钥并执行验签
// 3. 查设备 + active 校验
Device device = findDeviceBySn(sn);
if (device == null) {
throw new SecurityException("device not registered");
@@ -417,16 +414,19 @@ public class DeviceBindingService {
if (!device.isActive()) {
throw new SecurityException("device not active");
}
// 4. 验签RSA-PSS与设备端 signMetadata 参数一致)
try {
PublicKey pubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
if (!RsaUtil.verifySignature(challenge, signature, pubKey)) {
if (!RsaUtil.verifySignature(canonical, sig, pubKey)) {
throw new SecurityException("signature verification failed");
}
} catch (SecurityException e) {
throw e;
} catch (Exception e) {
if (e instanceof SecurityException) throw e;
throw new SecurityException("signature verification error");
}
return true;
return device;
}
// ==================== 响应模型 ====================

View File

@@ -39,7 +39,6 @@ app.upload.max-size-mb=20
# 取值为 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 常见端口)。

View File

@@ -17,6 +17,8 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
@@ -25,14 +27,18 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* 端到端集成测试
*
* 端到端集成测试(方案 AHeader 设备签名认证,零 challenge 往返)。
*
* 模拟完整流程:
* 1. 设备生成 TEE 密钥对RSA-2048→ 注册
* 2. 用户绑定设备
* 3. 设备拍照 → 信封加密 → 元数据签名 → 上传(服务端验签
* 4. 用户下载并解密照片
* 5. 恢复出厂 → 新密钥对 → 短信验证 → 恢复 → Token 验证 → 取回 DEK
* 1. 用户注册Bearer Token+ 设备生成 TEE 密钥对 → 注册PoP自签名 challenge
* 2. 用户绑定设备(设备签名 Header
* 3. 设备拍照 → 信封加密 → 元数据签名 → 上传(设备签名 Header
* 4. 用户下载并解密照片Bearer Token
* 5. 恢复出厂 → 新密钥对 → 短信验证 → 恢复 → Token 验证 → 取回 DEK(设备签名 Header
*
* 设备签名认证一律通过 {@link #buildDeviceAuthHeader} 生成
* {@code Authorization: Device-Sig sn=...,ts=...,nonce=...,sig=...}RSA-PSS与服务端
* {@code DeviceAuthInterceptor} / {@code RsaUtil.verifySignature} 完全一致)。
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(locations = "classpath:application.properties")
@@ -49,17 +55,14 @@ public class IntegrationTest {
// ==================== 辅助方法 ====================
/** 生成 RSA-2048 密钥对(与 Android Keystore TEE 密钥一致,支持 OAEP 加密 + SHA256withRSA 签名 */
/** 生成 RSA-2048 密钥对(与 Android Keystore TEE 密钥一致) */
private KeyPair generateDeviceKeyPair() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
return kpg.generateKeyPair();
}
/**
* 从统一响应 ApiResponse{code,message,data} 中取出 data。
* 前后端分离后所有接口都走该包装结构,断言 code==0 成功。
*/
/** 从统一响应 ApiResponse{code,message,data} 中取出 data断言 code==0 成功 */
@SuppressWarnings("unchecked")
private Map<String, Object> unwrapData(ResponseEntity<Map> resp) {
assertEquals(HttpStatus.OK, resp.getStatusCode());
@@ -72,30 +75,44 @@ public class IntegrationTest {
return Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
}
/** 设备用 TEE 私钥签名元数据SHA256withRSA对应 Android DeviceCrypto.signMetadata */
private String signMetadata(String metadata, PrivateKey privateKey) throws Exception {
Signature sig = Signature.getInstance("SHA256withRSA");
/** 设备用 TEE 私钥做 RSA-PSS 签名(与 Android DeviceCrypto.signMetadata / 服务端 RsaUtil 一致 */
private String signPss(String data, PrivateKey privateKey) throws Exception {
Signature sig = Signature.getInstance("RSASSA-PSS");
sig.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
sig.initSign(privateKey);
sig.update(metadata.getBytes(StandardCharsets.UTF_8));
sig.update(data.getBytes(StandardCharsets.UTF_8));
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}
* 生成设备签名认证请求头(方案 A本地生成 ts:nonceTEE 私钥对
* {@code METHOD|path|ts|nonce} 做 RSA-PSS 签名。
*/
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");
private HttpHeaders buildDeviceAuthHeader(String sn, KeyPair kp, String method, String path) throws Exception {
String ts = String.valueOf(System.currentTimeMillis());
String nonce = "nonce-" + System.nanoTime();
String canonical = method + "|" + path + "|" + ts + "|" + nonce;
String sig = signPss(canonical, kp.getPrivate());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization",
"Device-Sig sn=" + sn + ",ts=" + ts + ",nonce=" + nonce + ",sig=" + sig);
return headers;
}
// 2. 用 TEE 私钥签名 challengePoP
String signature = signMetadata(challenge, privateKey);
/** 发送带设备签名认证头的 POST 请求 */
private ResponseEntity<Map> devicePost(String url, String sn, KeyPair kp, Object body) throws Exception {
HttpEntity<?> entity = new HttpEntity<>(body, buildDeviceAuthHeader(sn, kp, "POST", url));
return restTemplate.exchange(url, HttpMethod.POST, entity, Map.class);
}
/** 设备注册PoP自签名 challengechallenge 格式 ts:nonce */
private ResponseEntity<Map> registerWithPop(String sn, String publicKeyBase64, KeyPair kp) throws Exception {
String ts = String.valueOf(System.currentTimeMillis());
String nonce = "nonce-" + System.nanoTime();
String challenge = ts + ":" + nonce;
String signature = signPss(challenge, kp.getPrivate());
// 3. 注册
Map<String, String> req = new HashMap<>();
req.put("sn", sn);
req.put("publicKeyBase64", publicKeyBase64);
@@ -109,42 +126,54 @@ public class IntegrationTest {
@Test
public void testFullFlow_DeviceRegister_Bind_Upload_Recover() throws Exception {
// ===== 准备:生成设备密钥对(模拟 Android Keystore =====
// ===== 准备:用户注册Bearer Token+ 生成设备密钥对 =====
// 每次运行使用唯一 ID避免测试间/重复运行对共享 MySQL 的污染
String runId = String.valueOf(System.currentTimeMillis());
String sn = "SN-TEST-" + runId;
String userId = "user-" + runId;
String phone = "138" + (runId.substring(runId.length() - 8));
String password = "123456";
// 注册用户 → 获取用户 Bearer Token用于 Web 用户端接口)
Map<String, String> userReg = new HashMap<>();
userReg.put("userId", userId);
userReg.put("phone", phone);
userReg.put("password", password);
ResponseEntity<Map> userResp = restTemplate.postForEntity("/api/auth/register", userReg, Map.class);
Map<String, Object> userData = unwrapData(userResp);
String userToken = (String) userData.get("token");
assertNotNull(userToken);
System.out.println("[Prep] User registered, bearer token obtained");
KeyPair deviceKeyPair = generateDeviceKeyPair();
String publicKeyBase64 = pubKeyToBase64(deviceKeyPair);
String sn = "SN-TEST-001";
String userId = "user-001";
String phone = "13800138000";
// ===== Step 1: 设备注册PoP 验签) =====
ResponseEntity<Map> resp = registerWithPop(sn, publicKeyBase64, deviceKeyPair.getPrivate());
ResponseEntity<Map> resp = registerWithPop(sn, publicKeyBase64, deviceKeyPair);
Map<String, Object> data = unwrapData(resp);
String deviceId = (String) data.get("deviceId");
assertNotNull(deviceId);
System.out.println("[Step 1] Device registered (PoP): " + deviceId);
// ===== Step 2: 用户绑定设备 =====
// ===== Step 2: 用户绑定设备(设备签名 Header =====
Map<String, String> bindReq = new HashMap<>();
bindReq.put("userId", userId);
bindReq.put("sn", sn);
bindReq.put("phone", phone);
resp = devicePost("/api/device/bind", sn, deviceKeyPair, bindReq);
unwrapData(resp);
System.out.println("[Step 2] Device bound to user (device signature): " + userId);
resp = restTemplate.postForEntity("/api/device/bind", bindReq, Map.class);
assertEquals(HttpStatus.OK, resp.getStatusCode());
System.out.println("[Step 2] Device bound to user: " + userId);
// ===== Step 3: 设备拍照并信封加密 =====
// ===== Step 3: 设备拍照并信封加密上传 =====
byte[] photoBytes = "This is a secret photo taken by the device".getBytes();
// 3a. 生成随机 DEK
SecretKey dek = AesGcmUtil.generateKey();
// 3b. AES-GCM 加密照片
AesGcmUtil.EncryptedResult encResult = AesGcmUtil.encrypt(photoBytes, dek);
// 3c. 设备 TEE 私钥签名元数据(服务端将用设备公钥验签)
String metadata = sn + "|" + System.currentTimeMillis() + "|photo-001";
String metadataSignature = signMetadata(metadata, deviceKeyPair.getPrivate());
String metadataSignature = signPss(metadata, deviceKeyPair.getPrivate());
// 本地自检:确保元数据签名可被同一公钥验签通过(排除测试侧 PSS 参数问题)
assertTrue(RsaUtil.verifySignature(metadata, metadataSignature, deviceKeyPair.getPublic()),
"metadata signature should verify locally with device public key");
Map<String, String> uploadReq = new HashMap<>();
uploadReq.put("sn", sn);
@@ -155,26 +184,26 @@ public class IntegrationTest {
uploadReq.put("metadataSignature", metadataSignature);
uploadReq.put("metadata", metadata);
resp = restTemplate.postForEntity("/api/photo/upload", uploadReq, Map.class);
assertEquals(HttpStatus.OK, resp.getStatusCode(), "服务端应验签通过");
resp = devicePost("/api/photo/upload", sn, deviceKeyPair, uploadReq);
unwrapData(resp);
System.out.println("[Step 3] Encrypted photo uploaded (server verified signature)");
// 3d. 篡改元数据签名应被拒绝(验签生效验证)
Map<String, String> tamperedReq = new HashMap<>(uploadReq);
tamperedReq.put("metadata", sn + "|" + System.currentTimeMillis() + "|photo-tampered");
resp = restTemplate.postForEntity("/api/photo/upload", tamperedReq, Map.class);
HttpEntity<?> tamperedEntity = new HttpEntity<>(tamperedReq,
buildDeviceAuthHeader(sn, deviceKeyPair, "POST", "/api/photo/upload"));
resp = restTemplate.exchange("/api/photo/upload", HttpMethod.POST, tamperedEntity, Map.class);
assertEquals(HttpStatus.FORBIDDEN, resp.getStatusCode(), "签名不匹配的请求应被拒绝");
System.out.println("[Step 3-tamper] Tampered request rejected as expected");
// ===== Step 4: 用户下载并解密照片 =====
HttpHeaders headers = new HttpHeaders();
headers.set("X-User-Id", userId);
HttpEntity<?> entity = new HttpEntity<>(headers);
// ===== Step 4: 用户下载并解密照片Bearer Token =====
HttpHeaders bearerHeaders = new HttpHeaders();
bearerHeaders.set("Authorization", "Bearer " + userToken);
ResponseEntity<Map> photoResp = restTemplate.exchange(
"/api/photo/photo-001/decrypt", HttpMethod.GET, entity, Map.class);
"/api/photo/photo-001/decrypt", HttpMethod.GET,
new HttpEntity<>(bearerHeaders), Map.class);
Map<String, Object> photoData = unwrapData(photoResp);
String plaintextBase64 = (String) photoData.get("plaintextBase64");
byte[] decryptedPhoto = Base64.getDecoder().decode(plaintextBase64);
assertArrayEquals(photoBytes, decryptedPhoto);
@@ -188,30 +217,25 @@ public class IntegrationTest {
String newPublicKeyBase64 = pubKeyToBase64(newDeviceKeyPair);
// 5b. 新设备注册PoP 验签;同 SN → 旧设备自动停用)
resp = registerWithPop(sn, newPublicKeyBase64, newDeviceKeyPair.getPrivate());
resp = registerWithPop(sn, newPublicKeyBase64, newDeviceKeyPair);
data = unwrapData(resp);
String newDeviceId = (String) data.get("deviceId");
assertNotEquals(deviceId, newDeviceId);
System.out.println("[Step 5a] New device registered after factory reset (PoP): " + newDeviceId);
// 5c. 发送短信验证码demo 固定 000000;用户端接口需登录态
// 5c. 发送短信验证码(设备签名 Headerdemo 固定 000000
Map<String, String> smsReq = new HashMap<>();
smsReq.put("phone", phone);
HttpHeaders smsHeaders = new HttpHeaders();
smsHeaders.set("X-User-Id", userId);
resp = restTemplate.exchange("/api/device/sms/send", HttpMethod.POST,
new HttpEntity<>(smsReq, smsHeaders), Map.class);
assertEquals(HttpStatus.OK, resp.getStatusCode());
resp = devicePost("/api/device/sms/send", sn, newDeviceKeyPair, smsReq);
unwrapData(resp);
System.out.println("[Step 5b] SMS code sent (demo code: 000000)");
// 5d. 恢复设备(短信 + SN 归属双因子)
// 5d. 恢复设备(短信 + SN 归属双因子,设备签名 Header
Map<String, String> recoverReq = new HashMap<>();
recoverReq.put("userId", userId);
recoverReq.put("sn", sn);
recoverReq.put("smsCode", "000000");
recoverReq.put("newPublicKeyBase64", newPublicKeyBase64);
resp = restTemplate.postForEntity("/api/device/recover", recoverReq, Map.class);
resp = devicePost("/api/device/recover", sn, newDeviceKeyPair, recoverReq);
data = unwrapData(resp);
String encryptedToken = (String) data.get("encryptedRecoveryToken");
assertNotNull(encryptedToken);
@@ -226,7 +250,7 @@ public class IntegrationTest {
photoRecoverReq.put("deviceId", newDeviceId);
photoRecoverReq.put("recoveryToken", recoveryToken);
photoRecoverReq.put("userId", userId);
resp = restTemplate.postForEntity("/api/photo/recover", photoRecoverReq, Map.class);
resp = devicePost("/api/photo/recover", sn, newDeviceKeyPair, photoRecoverReq);
data = unwrapData(resp);
assertEquals(1, ((Number) data.get("photoCount")).intValue());
System.out.println("[Step 5d] DEK list fetched with validated token");
@@ -257,21 +281,26 @@ public class IntegrationTest {
*/
@Test
public void testRecoveryServiceDirectly() throws Exception {
// 每次运行使用唯一 ID避免测试间/重复运行对共享 MySQL 的污染
String runId = String.valueOf(System.currentTimeMillis());
String userId = "user-" + runId;
String phone = "139" + (runId.substring(runId.length() - 8));
String sn = "SN-TEST-" + runId;
// 准备用户
keyManagementService.registerUser("user-002", "13900139000");
keyManagementService.registerUser(userId, phone);
// 准备设备(注册 + 绑定 + 发短信,模拟真实时序)
KeyPair kp0 = generateDeviceKeyPair();
deviceBindingService.registerDevice("SN-TEST-002", pubKeyToBase64(kp0));
deviceBindingService.bindDeviceToUser("user-002", "SN-TEST-002");
deviceBindingService.sendSmsCode("13900139000");
deviceBindingService.registerDevice(sn, pubKeyToBase64(kp0));
deviceBindingService.bindDeviceToUser(userId, sn);
deviceBindingService.sendSmsCode(phone);
// 恢复(恢复出厂 → 新密钥对)
KeyPair kp1 = generateDeviceKeyPair();
DeviceBindingService.RecoveryResponse recoveryResp =
deviceBindingService.recoverDevice(
"user-002",
"SN-TEST-002",
userId,
sn,
"000000", // demo 固定验证码
pubKeyToBase64(kp1)
);

View File

@@ -1,40 +0,0 @@
-----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-----