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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
package com.secure.demo.network.model;
import com.google.gson.annotations.SerializedName;
/**
* 设备状态查询挑战值响应。
*
* 对应后端 GET /api/device/challenge 的 data 字段:
* { challenge }
*
* challenge 格式:<timestampMillis>:<nonce>,由设备端使用 TEE 私钥签名后,
* 回传给 POST /api/device/status 完成 Challenge-Response 认证。
*/
public class ChallengeResponse {
@SerializedName("challenge")
private String challenge;
public String getChallenge() { return challenge; }
public void setChallenge(String challenge) { this.challenge = challenge; }
}

View File

@@ -1,34 +0,0 @@
package com.secure.demo.network.model;
import com.google.gson.annotations.SerializedName;
/**
* 设备状态查询(带设备签名认证)请求体。
*
* 对应后端 POST /api/device/status 的 body
* { sn, challenge, signature }
*
* - challenge由 GET /api/device/challenge 下发,格式 <timestampMillis>:<nonce>
* - signature设备使用 TEE 私钥对 challenge 的签名Base64
*/
public class DeviceStatusRequest {
@SerializedName("sn")
private String sn;
@SerializedName("challenge")
private String challenge;
@SerializedName("signature")
private String signature;
public DeviceStatusRequest(String sn, String challenge, String signature) {
this.sn = sn;
this.challenge = challenge;
this.signature = signature;
}
public String getSn() { return sn; }
public String getChallenge() { return challenge; }
public String getSignature() { return signature; }
}

View File

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

View File

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

View File

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

View File

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