diff --git a/README.md b/README.md index aa50674..e000bd9 100644 --- a/README.md +++ b/README.md @@ -62,19 +62,22 @@ ### Phase 1:设备注册 + 用户绑定 +> **方案 A(Header 设备签名认证)**:设备不再先 `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 签名),服务端验签通过才允许注册,防止攻击者用自己公钥冒名注册受害者 SN(P0-1 修复)。App 启动时建议先调 `GET /api/device/status?sn=xxx`,若 `registered=true` 且 `bound=true`,直接复用返回的 `deviceId`/`userId`,跳过注册与绑定。 +> **注册/绑定幂等说明**:`/api/device/register` 对「同一 SN + 相同公钥」幂等——App 重启后重复注册不会生成新 deviceId,也不会误停用自己;仅当公钥变化(恢复出厂)时才停旧换新。注册必须携带 PoP(challenge 由设备本地生成 `ts:nonce`,用 TEE 私钥签名,服务端用请求内公钥验签),防止攻击者用自己公钥冒名注册受害者 SN。App 启动时建议先调 `POST /api/device/status`,若 `registered=true` 且 `bound=true`,直接复用返回的 `deviceId`/`userId`,跳过注册与绑定。 ### 用户端(需要登录 Token) diff --git a/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java b/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java index 525cd4d..539fafe 100644 --- a/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java +++ b/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java @@ -12,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 签名的设备注册请求链(方案 A:challenge 由设备本地生成,零 GET /challenge 往返)。 + * + * challenge 格式 {@code ts:nonce}:设备本地取当前毫秒时间戳 + 随机 nonce, + * 用 TEE 私钥签名后上报;服务端用「请求内上传的公钥」验签(PoP), + * 证明请求方确实拥有该公钥对应私钥,防止冒名注册。 * * 供首次注册与恢复出厂后重新注册复用。 */ private Single 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 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 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 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 Single deviceSignedAuthFlatMap( - io.reactivex.rxjava3.functions.Function> 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 { } } + /** + * 生成设备本地一次性 challenge(PoP 用),格式 {@code :}。 + * 服务端按时间戳校验时效、按 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) { diff --git a/android-app/app/src/main/java/com/secure/demo/network/ApiClient.java b/android-app/app/src/main/java/com/secure/demo/network/ApiClient.java index ea19b4b..7cb8018 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/ApiClient.java +++ b/android-app/app/src/main/java/com/secure/demo/network/ApiClient.java @@ -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) { + /** + * 配置设备签名认证(方案 A:Header 自动签名)。 + * 需在设备本地 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 注销 */ diff --git a/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java b/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java index ade3f4c..3ba5841 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java +++ b/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java @@ -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 展示 / 日志使用,不参与任何鉴权。 + *

替代旧的「先 GET /challenge 再签名」两段式流程:本拦截器在每个需要设备认证的请求上, + * 自动用 TEE 私钥对 {@code METHOD|path|ts|nonce} 签名,并写入请求头:

+ *
+ *   Authorization: Device-Sig sn=<sn>,ts=<ms>,nonce=<hex>,sig=<base64>
+ * 
+ *

服务端 {@code DeviceAuthInterceptor} 统一验签(时效 + nonce 防重放 + RSA-PSS), + * 业务接口零额外认证代码、零额外往返。

+ * + *

仅对设备签名认证路径注入头;注册接口(PoP,用请求内公钥验签)显式排除。

*/ 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(); } } diff --git a/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java b/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java index 62100be..a1f50a0 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java +++ b/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java @@ -2,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> deviceStatusChallenge(); - - /** 0b. 查询设备注册/绑定状态(设备端无登录,需设备签名认证) */ + /** 查询设备注册/绑定状态(设备签名认证由拦截器注入,无需请求体) */ @POST("api/device/status") - Single> deviceStatus(@Body DeviceStatusRequest body); + Single> deviceStatus(); - /** 1. 设备注册(SN + 公钥,设备端无登录) */ + /** 1. 设备注册(SN + 公钥 + PoP 自签名 challenge,设备端无登录) */ @POST("api/device/register") Single> registerDevice(@Body RegisterRequest body); - /** 2. 绑定设备(零信任:设备签名认证,sn + challenge + signature + userId) */ + /** 2. 绑定设备(设备签名认证由拦截器注入) */ @POST("api/device/bind") Single> bindDevice(@Body BindRequest body); - /** 3. 发送短信验证码(零信任:设备签名认证,防短信轰炸) */ + /** 3. 发送短信验证码(设备签名认证由拦截器注入,防短信轰炸) */ @POST("api/device/sms/send") Single> sendSms(@Body SmsRequest body); - /** 4. 上传加密照片(信封加密,DEK 明文经 HTTPS 传输,设备端无登录) */ + /** 4. 上传加密照片(信封加密,设备签名认证由拦截器注入) */ @POST("api/photo/upload") Single> uploadPhoto(@Body UploadPhotoRequest body); - /** 5. 恢复设备(零信任:设备签名认证,短信验证 + 新公钥,换机/恢复出厂后调用) */ + /** 5. 恢复设备(设备签名认证由拦截器注入,短信验证 + 新公钥,换机/恢复出厂后调用) */ @POST("api/device/recover") Single> recoverDevice(@Body RecoverRequest body); @@ -73,12 +69,11 @@ public interface DeviceApi { Single> recoverPhotos(@Body PhotoRecoverRequest body); /** - * 6b. 设备本地下载解密单张照片(端到端加密,设备端 Challenge-Response 签名认证)。 + * 6b. 设备本地下载解密单张照片(端到端加密,设备签名认证由拦截器注入)。 * 服务端下发「密文 + 设备公钥加密的 DEK + IV」,设备在 TEE 内本地解密,服务端不接触明文。 */ @POST("api/device/photo/{photoId}/local") - Single> deviceDownloadLocal(@Path("photoId") String photoId, - @Body DeviceStatusRequest body); + Single> 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 downloadAndDecryptStreaming(@Path("photoId") String photoId); - /** 7b. 设备照片 ID 列表(零信任:设备签名认证,返回该设备绑定用户的照片列表) */ + /** 7b. 设备照片 ID 列表(设备签名认证由拦截器注入,返回该设备绑定用户的照片列表) */ @POST("api/device/photos") - Single> devicePhotos(@Body DeviceStatusRequest body); + Single> devicePhotos(); - /** 8. 设备照片元数据列表(零信任:设备签名认证,含上传时间/来源设备/是否可设备端解密,供展示用) */ + /** 8. 设备照片元数据列表(设备签名认证由拦截器注入,含上传时间/来源设备/是否可设备端解密) */ @POST("api/device/photos/metadata") - Single> devicePhotosMetadata(@Body DeviceStatusRequest body); + Single> devicePhotosMetadata(); - /** 9. 获取服务端传输公钥(设备端用它加密上传的 DEK,使 DEK 在网络上不明文) */ - @GET("api/device/transport-key") - Single>> transportPublicKey(); - - /** 8. 健康检查(公开) */ + /** 9. 健康检查(公开) */ @GET("api/health") Single> health(); } diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java index 325a296..07a97c9 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java +++ b/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java @@ -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 / signature:Challenge-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; } } diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/ChallengeResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/ChallengeResponse.java deleted file mode 100644 index d3cfa99..0000000 --- a/android-app/app/src/main/java/com/secure/demo/network/model/ChallengeResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.secure.demo.network.model; - -import com.google.gson.annotations.SerializedName; - -/** - * 设备状态查询挑战值响应。 - * - * 对应后端 GET /api/device/challenge 的 data 字段: - * { challenge } - * - * challenge 格式::,由设备端使用 TEE 私钥签名后, - * 回传给 POST /api/device/status 完成 Challenge-Response 认证。 - */ -public class ChallengeResponse { - - @SerializedName("challenge") - private String challenge; - - public String getChallenge() { return challenge; } - public void setChallenge(String challenge) { this.challenge = challenge; } -} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/DeviceStatusRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/DeviceStatusRequest.java deleted file mode 100644 index a5c6193..0000000 --- a/android-app/app/src/main/java/com/secure/demo/network/model/DeviceStatusRequest.java +++ /dev/null @@ -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 下发,格式 : - * - signature:设备使用 TEE 私钥对 challenge 的签名(Base64) - */ -public class DeviceStatusRequest { - - @SerializedName("sn") - private String sn; - - @SerializedName("challenge") - private String challenge; - - @SerializedName("signature") - private String signature; - - public DeviceStatusRequest(String sn, String challenge, String signature) { - this.sn = sn; - this.challenge = challenge; - this.signature = signature; - } - - public String getSn() { return sn; } - public String getChallenge() { return challenge; } - public String getSignature() { return signature; } -} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java index e9071c8..e01db93 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java +++ b/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java @@ -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 / signature:Challenge-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; } } diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java index 8ca3d1f..37fced1 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java +++ b/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java @@ -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 / signature:Challenge-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; } } diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java index ed6867a..ad9d61a 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java +++ b/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java @@ -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 } * * 安全要点: - * - encryptedDekBase64:DEK 用「服务端传输公钥」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; } } diff --git a/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java b/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java index 252a1a4..beb8383 100644 --- a/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java +++ b/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java @@ -265,41 +265,6 @@ public class DeviceCrypto { } } - // ==================== 传输层 DEK 加密(服务端公钥) ==================== - - /** - * 用「服务端传输公钥」RSA-OAEP 加密 DEK,使 DEK 在网络上永不明文(纵深防御)。 - * - * 设备上传照片时,不再把明文 DEK 随包上传;而是先用服务端下发的传输公钥加密 DEK, - * 上传 {@code encryptedDekBase64}。服务端用其持有的传输私钥解出 DEK 后,再交由 UK 信封加密存储。 - * 即使传输层被中间人截获,攻击者也无法还原 DEK、进而无法解密照片。 - * - * @param dekBytes 明文 DEK(AES-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); - } - } - // ==================== 元数据签名 ==================== /** diff --git a/springboot-server/src/main/java/com/secure/demo/config/DeviceAuthInterceptor.java b/springboot-server/src/main/java/com/secure/demo/config/DeviceAuthInterceptor.java new file mode 100644 index 0000000..cc18583 --- /dev/null +++ b/springboot-server/src/main/java/com/secure/demo/config/DeviceAuthInterceptor.java @@ -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 承载)。 + * + *

替代旧的「先 GET /challenge 再签名」两段式流程,设备本地生成时间戳与随机 nonce, + * 用 TEE 私钥对 {@code METHOD|path|ts|nonce} 签名后放入请求头,服务端在拦截器内 + * 统一完成验签,业务接口零额外认证代码、零额外往返。

+ * + *

请求头格式:

+ *
+ *   Authorization: Device-Sig sn=SN-001,ts=1730000000000,nonce=abc123,sig=
+ * 
+ * + *

验签通过后,认证通过的 {@link com.secure.demo.model.Device} 会被放入 request attribute + * {@link #ATTR_DEVICE},业务接口直接取出使用。

+ * + *

仅对需要设备认证的接口生效({@link #isDevicePath}),注册/登录/健康检查等公开接口由 + * {@link WebConfig} 显式排除。

+ */ +@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()); + } +} diff --git a/springboot-server/src/main/java/com/secure/demo/config/WebConfig.java b/springboot-server/src/main/java/com/secure/demo/config/WebConfig.java index af903da..adb708e 100644 --- a/springboot-server/src/main/java/com/secure/demo/config/WebConfig.java +++ b/springboot-server/src/main/java/com/secure/demo/config/WebConfig.java @@ -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 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 diff --git a/springboot-server/src/main/java/com/secure/demo/controller/DeviceController.java b/springboot-server/src/main/java/com/secure/demo/controller/DeviceController.java index f57ff24..4053fe0 100644 --- a/springboot-server/src/main/java/com/secure/demo/controller/DeviceController.java +++ b/springboot-server/src/main/java/com/secure/demo/controller/DeviceController.java @@ -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 控制器(前后端分离版,方案 A:Header 设备签名认证,零 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 ): │ - * │ 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 我的设备列表(新增) │ - * └────────────────────────────────────────────────────────────────┘ + *

设备签名认证改为「请求头 + 时间戳/nonce 自校验」:设备用 TEE 私钥对 + * {@code METHOD|path|ts|nonce} 签名放入 {@code Authorization: Device-Sig ...} 头, + * 由 {@link DeviceAuthInterceptor} 统一验签后把设备放入 request attribute。

* - * 鉴权优先级(用户端接口):Bearer Token > X-User-Id Header(Android - * demo 兼容)> body.userId(集成测试兼容)。生产环境只保留 Bearer Token。 + *

接口分组:

+ *
+ *  公开(无需设备认证):
+ *    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 <token>):
+ *    GET  /api/photo/{id}/decrypt            下载并解密照片
+ *    GET  /api/photo/{id}/decrypt/stream     下载并解密照片(流式)
+ *    GET  /api/user/photos                   我的照片列表
+ *    GET  /api/user/photos/metadata          我的照片元数据
+ *    GET  /api/user/devices                  我的设备列表
+ * 
*/ @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. 验签通过后才允许注册/停旧换新 + *

注册接口保持公开(设备尚未入库),用请求内携带的公钥做 PoP 验签: + * 设备先本地生成 {@code challenge = ts:nonce},用 TEE 私钥签名后上报, + * 服务端用「请求内上传的公钥」验签,证明私钥持有者确实拥有该公钥,防止冒名注册。

* - * 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> registerDevice(@RequestBody Map req) { @@ -157,85 +148,51 @@ public class DeviceController { )); } - /** - * 获取设备状态查询用的挑战值(Challenge-Response 第一步)。 - * - * 设备端先调用本接口拿到挑战值,使用 TEE 私钥对 challenge 签名后, - * 再调用 POST /api/device/status 完成认证并返回状态。 - */ - @GetMapping("/device/challenge") - public ApiResponse> deviceStatusChallenge() { - String challenge = deviceBindingService.generateStatusChallenge(); - Map body = new java.util.LinkedHashMap<>(); - body.put("challenge", challenge); - return ApiResponse.ok(body); - } + // ==================== 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> 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> 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 对应用户。 + *

认证方式:

+ *
    + *
  • Android 设备端:请求头 Device-Sig(拦截器认证),绑定目标 userId 取自 body;
  • + *
  • Web 用户端:Authorization: Bearer <token>,绑定目标为 Token 对应用户。
  • + *
* - * 不再信任 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> bindDevice(@RequestBody Map 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> sendSms(@RequestBody Map 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> devicePhotos(@RequestBody Map req) { - Device device = authenticateDeviceBySignature(req); + public ApiResponse> 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> devicePhotosMetadata(@RequestBody Map req) { - Device device = authenticateDeviceBySignature(req); + public ApiResponse> 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> 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> transportPublicKey() { - return ApiResponse.ok(Map.of( - "publicKeyBase64", transportKeyService.getPublicKeyBase64() - )); - } - - // ==================== 4. 上传加密照片(设备端,无登录) ==================== - - /** - * 设备上传加密照片(信封加密,设备私钥签名防伪) + *

DEK 由设备端随机生成并随包上传明文(demo 无 HTTPS 时为纵深防御的取舍, + * 生产环境应启用 HTTPS 或恢复传输公钥加密)。

* - * 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> uploadPhoto(@RequestBody Map req) { - String sn = req.get("sn"); + public ApiResponse> uploadPhoto(@RequestBody Map 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. 验签:用设备公钥验证 metadataSignature(SHA256withRSA),防伪/防篡改 + // 2. 验签:用设备公钥验证 metadataSignature(RSA-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 。 - * - * Request: { "sn": "...", "challenge": "...", "signature": "...", "smsCode": "...", "newPublicKeyBase64": "..." } - * Response: data = { "deviceId": "...", "encryptedRecoveryToken": "...", "nonce": "...", "message": "..." } + * Request: { "sn", "smsCode", "newPublicKeyBase64" } */ @PostMapping("/device/recover") public ApiResponse> recoverDevice(@RequestBody Map 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> recoverPhotos(@RequestBody Map req) { + public ApiResponse> recoverPhotos(@RequestBody Map 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. 验证 recoveryToken:deviceId 匹配 + 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> 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> 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)} 功能一致,但不再把明文塞进 - * JSON(Base64 膨胀 ~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 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 解密 DEK(DEK 明文仅在解密循环内短暂可见) 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> getUserPhotos(HttpServletRequest httpRequest) { - String userId = resolveUserId(httpRequest, null); + String userId = resolveUserId(httpRequest); List 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> getUserPhotoMetadata(HttpServletRequest httpRequest) { - String userId = resolveUserId(httpRequest, null); + String userId = resolveUserId(httpRequest); List> 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> getUserDevices(HttpServletRequest httpRequest) { - String userId = resolveUserId(httpRequest, null); + String userId = resolveUserId(httpRequest); List devices = deviceBindingService.getDevicesByUser(userId); List> list = new ArrayList<>(); @@ -853,19 +688,9 @@ public class DeviceController { // ==================== 鉴权辅助 ==================== /** - * 解析当前请求的用户 ID。 - * - * 优先级: - * 1. Authorization: Bearer (Web 用户端登录后携带,真实场景唯一方式) - * 2. X-User-Id Header(Android 设备端 demo 兼容,AuthInterceptor 自动注入) - * 3. body.userId(集成测试 / 旧调用兼容) - * - * 均无 → 401 Unauthorized + * 从请求头解析用户 ID(仅允许 Bearer Token)。 */ - private String resolveUserId(HttpServletRequest request, Map body) { - // 仅允许 Bearer Token(Web 用户端)。 - // 零信任原则:不再信任 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 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(); diff --git a/springboot-server/src/main/java/com/secure/demo/controller/model/DeviceLocalPhotoRequest.java b/springboot-server/src/main/java/com/secure/demo/controller/model/DeviceLocalPhotoRequest.java deleted file mode 100644 index 27e80a4..0000000 --- a/springboot-server/src/main/java/com/secure/demo/controller/model/DeviceLocalPhotoRequest.java +++ /dev/null @@ -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; // 服务端下发的挑战值,格式 : - private String signature; // 设备使用 TEE 私钥对 challenge 的签名(Base64) - - public String getSn() { return sn; } - public void setSn(String sn) { this.sn = sn; } - - public String getChallenge() { return challenge; } - public void setChallenge(String challenge) { this.challenge = challenge; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} diff --git a/springboot-server/src/main/java/com/secure/demo/controller/model/StatusChallengeRequest.java b/springboot-server/src/main/java/com/secure/demo/controller/model/StatusChallengeRequest.java deleted file mode 100644 index 17c6eb0..0000000 --- a/springboot-server/src/main/java/com/secure/demo/controller/model/StatusChallengeRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.secure.demo.controller.model; - -/** - * 设备状态查询(带设备签名认证)请求体 - */ -public class StatusChallengeRequest { - private String sn; - private String challenge; // 服务端下发的挑战值,格式 : - private String signature; // 设备使用 TEE 私钥对 challenge 的签名(Base64) - - public String getSn() { return sn; } - public void setSn(String sn) { this.sn = sn; } - - public String getChallenge() { return challenge; } - public void setChallenge(String challenge) { this.challenge = challenge; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} diff --git a/springboot-server/src/main/java/com/secure/demo/crypto/TransportKeyService.java b/springboot-server/src/main/java/com/secure/demo/crypto/TransportKeyService.java deleted file mode 100644 index fd39752..0000000 --- a/springboot-server/src/main/java/com/secure/demo/crypto/TransportKeyService.java +++ /dev/null @@ -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)管理。 - * - *

用途:解决「设备上传时 DEK 明文随包传输」的问题。 - * 设备端在传输层(HTTPS 之外)对 DEK 再加密: - * - 设备先 GET /api/device/transport-key 获取本服务端传输公钥; - * - 设备用该公钥(RSA-OAEP)加密 DEK,上传 {@code encryptedDekBase64}(不再上传明文 DEK); - * - 服务端用本类持有的传输私钥解出明文 DEK,再交给 KeyManagementService.wrapDEK 用 UK 包裹存储。 - * - *

这样即使传输层被中间人截获,攻击者拿到的也只是「被服务端私钥保护的加密 DEK」, - * 无法还原明文 DEK,也就无法解密照片(满足零知识 / 纵深防御)。

- * - *

私钥来源优先级(从上到下): - * 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}, - * 指向一个私钥文件,支持 PEM(OpenSSL 默认,含 {@code -----BEGIN PRIVATE KEY-----} 头)或 DER 格式 - * —— 推荐生产方式,私钥文件单独保存并限权,不进入代码仓库; - * 3. 若皆缺失,本次启动随机生成(仅本地联调,重启后旧密钥加密的数据无法解密)。

- */ -@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= to persist."); - } catch (Exception e) { - throw new IllegalStateException("Failed to generate ephemeral transport key pair", e); - } - } - - /** 从内联 Base64(PKCS#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); - } - } -} diff --git a/springboot-server/src/main/java/com/secure/demo/service/DeviceBindingService.java b/springboot-server/src/main/java/com/secure/demo/service/DeviceBindingService.java index e45d3b4..1639152 100644 --- a/springboot-server/src/main/java/com/secure/demo/service/DeviceBindingService.java +++ b/springboot-server/src/main/java/com/secure/demo/service/DeviceBindingService.java @@ -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) ==================== - - /** - * 生成一个用于设备注册认证的一次性挑战值(PoP,Proof of Possession)。 - * 格式:: - * - timestampMillis 用于时效校验(CHALLENGE_VALID_WINDOW_MS 内有效) - * - nonce 用于防重放(一次性消费) - * - *

与 {@link #generateStatusChallenge()} 语义一致,但用于注册前证明 - * 「请求方确实拥有所上传公钥对应的 TEE 私钥」,避免攻击者用自己公钥冒名注册。

- */ - public String generateRegisterChallenge() { - return generateStatusChallenge(); - } + // ==================== 7. 设备注册认证(PoP) ==================== /** * 校验注册请求的 PoP 签名:用请求内上传的公钥验证设备对 challenge 的签名。 * - *

与 {@link #verifyStatusChallenge} 的区别:注册时设备可能尚未入库,因此不能 - * 按 SN 查库取公钥,而是直接用请求体携带的 {@code publicKeyBase64} 验签。 - * 验签通过即证明「该公钥的私钥持有者」发起了本次注册,从而绑定 SN ↔ TEE 私钥。

+ *

注册时设备尚未入库,因此不能按 SN 查库取公钥,而是直接用请求体携带的 + * {@code publicKeyBase64} 验签。challenge 由设备本地生成(格式 {@code ts:nonce}, + * 用 TEE 私钥签名后上报)。验签通过即证明「该公钥的私钥持有者」发起了本次注册, + * 从而绑定 SN ↔ TEE 私钥,防止攻击者用自己公钥冒名注册受害者 SN。

* * @param publicKeyBase64 请求上传的设备公钥 - * @param challenge 服务端下发的挑战值,格式 : + * @param challenge 设备本地生成的挑战值,格式 : * @param signature 设备用 TEE 私钥对 challenge 的签名(Base64) */ public void verifyRegisterChallenge(String publicKeyBase64, String challenge, String signature) { @@ -359,57 +367,46 @@ public class DeviceBindingService { } } - /** - * 生成一个用于设备状态查询认证的一次性挑战值。 - * 格式:: - * - 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 必须是首次使用(一次性,防重放) - * - 使用设备已注册公钥验签(RSA,对应设备 TEE 私钥签名) + *

设备本地生成时间戳 {@code ts} 与随机 {@code nonce},用 TEE 私钥对 + * {@code canonical}(METHOD|path|ts|nonce)签名;本方法:

+ *
    + *
  1. 校验 {@code ts} 在 {@link #CHALLENGE_VALID_WINDOW_MS} 时效窗口内;
  2. + *
  3. 校验 {@code nonce} 一次性(防重放,复用 status nonce 防重放池);
  4. + *
  5. 按 {@code sn} 查库取设备并校验 active;
  6. + *
  7. 用设备公钥对 {@code canonical} 验签。
  8. + *
* - * @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; } // ==================== 响应模型 ==================== diff --git a/springboot-server/src/main/resources/application.properties b/springboot-server/src/main/resources/application.properties index 83319a5..c0015c5 100644 --- a/springboot-server/src/main/resources/application.properties +++ b/springboot-server/src/main/resources/application.properties @@ -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 常见端口)。 diff --git a/springboot-server/src/test/java/com/secure/demo/IntegrationTest.java b/springboot-server/src/test/java/com/secure/demo/IntegrationTest.java index e1bbe2a..5710221 100644 --- a/springboot-server/src/test/java/com/secure/demo/IntegrationTest.java +++ b/springboot-server/src/test/java/com/secure/demo/IntegrationTest.java @@ -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.*; /** - * 端到端集成测试 - * + * 端到端集成测试(方案 A:Header 设备签名认证,零 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 unwrapData(ResponseEntity 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:nonce,TEE 私钥对 + * {@code METHOD|path|ts|nonce} 做 RSA-PSS 签名。 */ - private ResponseEntity registerWithPop(String sn, String publicKeyBase64, PrivateKey privateKey) throws Exception { - // 1. 取挑战值 - ResponseEntity chResp = restTemplate.getForEntity("/api/device/challenge", Map.class); - Map 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 私钥签名 challenge(PoP) - String signature = signMetadata(challenge, privateKey); + /** 发送带设备签名认证头的 POST 请求 */ + private ResponseEntity 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,自签名 challenge,challenge 格式 ts:nonce) */ + private ResponseEntity 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 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 userReg = new HashMap<>(); + userReg.put("userId", userId); + userReg.put("phone", phone); + userReg.put("password", password); + ResponseEntity userResp = restTemplate.postForEntity("/api/auth/register", userReg, Map.class); + Map 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 resp = registerWithPop(sn, publicKeyBase64, deviceKeyPair.getPrivate()); + ResponseEntity resp = registerWithPop(sn, publicKeyBase64, deviceKeyPair); Map 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 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 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 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 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 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. 发送短信验证码(设备签名 Header;demo 固定 000000) Map 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 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) ); diff --git a/springboot-server/transport_private_key.pem b/springboot-server/transport_private_key.pem deleted file mode 100644 index bef8387..0000000 --- a/springboot-server/transport_private_key.pem +++ /dev/null @@ -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-----