refactor(android): 改用设备签名拦截器认证,移除 challenge 往返
设备接口统一由 AuthInterceptor 注入 Device-Sig 头完成认证,删除手动 challenge 签名流程与 transport-key 接口,注册改用本地生成 ts:nonce 做 PoP 验签。
This commit is contained in:
@@ -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<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 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成设备本地一次性 challenge(PoP 用),格式 {@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) {
|
||||
|
||||
@@ -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 注销 */
|
||||
|
||||
@@ -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=<sn>,ts=<ms>,nonce=<hex>,sig=<base64>
|
||||
* </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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 元数据签名 ====================
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user