fix(device): 设备注册绑定接口添加 PoP 验签,照片下载改为设备端本地解密
This commit is contained in:
@@ -102,8 +102,12 @@ public class MainActivity extends BaseMvvmActivity<ActivityMainBinding, MainView
|
||||
}
|
||||
});
|
||||
});
|
||||
viewModel.ui.getStatus().observe(this, binding.status::setText);
|
||||
viewModel.ui.getBusy().observe(this, busy -> updateControlsState());
|
||||
viewModel.ui.getStatus().observe(this, status -> renderStatus(status));
|
||||
viewModel.ui.getBusy().observe(this, busy -> {
|
||||
updateControlsState();
|
||||
// 忙状态变化时重渲染状态摘要(保留完整状态,仅附加忙碌提示行)
|
||||
renderStatus(viewModel.ui.getStatus().getValue());
|
||||
});
|
||||
viewModel.ui.getCurrentStep().observe(this, step -> updateControlsState());
|
||||
viewModel.ui.getRegistered().observe(this, registered -> updateControlsState());
|
||||
viewModel.ui.getBound().observe(this, bound -> updateControlsState());
|
||||
@@ -122,6 +126,12 @@ public class MainActivity extends BaseMvvmActivity<ActivityMainBinding, MainView
|
||||
displayDecryptedImage(base64);
|
||||
}
|
||||
});
|
||||
// ④b 流式下载解密结果(二进制字节,后端 StreamingResponseBody 直出,无需 Base64)
|
||||
viewModel.ui.getDecryptedImageBytes().observe(this, plaintextBytes -> {
|
||||
if (plaintextBytes != null && plaintextBytes.length > 0) {
|
||||
displayDecryptedImage(plaintextBytes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,10 +141,24 @@ public class MainActivity extends BaseMvvmActivity<ActivityMainBinding, MainView
|
||||
* 解码完成后通过 runOnUiThread 回到主线程更新 ImageView。
|
||||
*/
|
||||
private void displayDecryptedImage(String base64) {
|
||||
try {
|
||||
byte[] bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT);
|
||||
displayDecryptedImage(bytes);
|
||||
} catch (Exception e) {
|
||||
// 非图片明文(如纯文本测试数据)无法解码为 Bitmap,忽略即可
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将解密后的图片明文(二进制字节)解码为 Bitmap 并显示到预览区。
|
||||
*
|
||||
* 供「④b 流式下载解密」使用:后端以 StreamingResponseBody 直接写出原始二进制明文,
|
||||
* 此处无需 Base64 解码,直接交给 BitmapFactory。Bitmap 解码属 CPU 密集操作,放到后台线程。
|
||||
*/
|
||||
private void displayDecryptedImage(byte[] bytes) {
|
||||
java.util.concurrent.ExecutorService exec = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
exec.execute(() -> {
|
||||
try {
|
||||
byte[] bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT);
|
||||
android.graphics.Bitmap bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
|
||||
runOnUiThread(() -> {
|
||||
if (bitmap != null) {
|
||||
@@ -171,6 +195,11 @@ public class MainActivity extends BaseMvvmActivity<ActivityMainBinding, MainView
|
||||
binding.btnUploadImage.setEnabled(idle && bound);
|
||||
// 下载解密:已绑定时可点(ViewModel 内部对「尚未上传」有 toast 兜底)
|
||||
binding.btnDownload.setEnabled(idle && bound);
|
||||
// 下载解密(流式):与「下载解密」同条件可用
|
||||
binding.btnDownloadStreaming.setEnabled(idle && bound);
|
||||
// 列出全部照片 / 设备本地解密全部:已绑定时可用
|
||||
binding.btnListPhotos.setEnabled(idle && bound);
|
||||
binding.btnDownloadAllLocal.setEnabled(idle && bound);
|
||||
// 恢复出厂:只要初始化完成即可点(本地 TEE 操作,与注册/绑定无关)
|
||||
binding.btnReset.setEnabled(idle);
|
||||
|
||||
@@ -189,9 +218,17 @@ public class MainActivity extends BaseMvvmActivity<ActivityMainBinding, MainView
|
||||
|
||||
// ---- 文字反馈 ----
|
||||
binding.btnUploadImage.setText(isBusy && step == 3 ? "上传中..." : "③ 选择并上传图片");
|
||||
if (isBusy) {
|
||||
binding.status.setText("⏳ 正在处理,请稍候...");
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染状态摘要:忙时在完整状态前附加一行忙碌提示,不覆盖 ViewModel 提供的完整状态。
|
||||
*/
|
||||
private void renderStatus(String status) {
|
||||
String text = status == null ? "" : status;
|
||||
if (Boolean.TRUE.equals(viewModel.ui.getBusy().getValue())) {
|
||||
text = "⏳ 正在处理,请稍候...\n" + text;
|
||||
}
|
||||
binding.status.setText(text);
|
||||
}
|
||||
|
||||
/** 权限适配:Android 10+ 无需权限,直接 SAF;旧设备按需申请 */
|
||||
@@ -264,6 +301,21 @@ public class MainActivity extends BaseMvvmActivity<ActivityMainBinding, MainView
|
||||
viewModel.downloadLastPhoto();
|
||||
}
|
||||
|
||||
/** ④b 下载解密照片(流式 StreamingResponseBody,后端二进制直出) */
|
||||
public void downloadPhotoStreaming(View view) {
|
||||
viewModel.downloadLastPhotoStreaming();
|
||||
}
|
||||
|
||||
/** ④c 列出全部照片元数据 */
|
||||
public void listAllPhotos(View view) {
|
||||
viewModel.listAllPhotos();
|
||||
}
|
||||
|
||||
/** ④d 设备本地下载解密全部照片(端到端加密,TEE 本地解密) */
|
||||
public void downloadAllPhotosLocal(View view) {
|
||||
viewModel.downloadAllPhotosLocal();
|
||||
}
|
||||
|
||||
/** ⑤ 恢复出厂 */
|
||||
public void resetDevice(View view) {
|
||||
viewModel.resetDevice();
|
||||
|
||||
@@ -13,11 +13,13 @@ 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;
|
||||
import com.secure.demo.network.model.MessageResponse;
|
||||
import com.secure.demo.network.model.PhotoMetadataResponse;
|
||||
import com.secure.demo.network.model.PhotoRecoverRequest;
|
||||
import com.secure.demo.network.model.PhotoRecoverResponse;
|
||||
import com.secure.demo.network.model.RecoverRequest;
|
||||
@@ -43,6 +45,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
/**
|
||||
* 主界面 ViewModel(标准 MVVM)。
|
||||
@@ -279,24 +282,44 @@ public class MainViewModel extends BaseViewModel {
|
||||
addDisposable(d);
|
||||
}
|
||||
|
||||
// ==================== ① 设备注册 ====================
|
||||
// ==================== ① 设备注册(PoP 验签) ====================
|
||||
|
||||
/**
|
||||
* 注册设备(设备端无登录):SN + 公钥 上报后端。
|
||||
* 注册设备(设备端无登录):先取 challenge,TEE 私钥签名后上报 SN + 公钥 + 签名。
|
||||
* 真实场景:用户购买设备后首次联网激活。
|
||||
* 修复 P0-1:注册携带 PoP(challenge + TEE 私钥签名),服务端验签通过才允许注册,
|
||||
* 防止攻击者用自己公钥冒名注册受害者 SN。
|
||||
*/
|
||||
public void registerDevice() {
|
||||
if (!requireCrypto()) return;
|
||||
ui.appendLog("—— ① 设备注册 ——");
|
||||
runStep("设备注册",
|
||||
ApiClient.deviceApi().registerDevice(new RegisterRequest(sn, pubKey))
|
||||
.map(ApiResponse::getData),
|
||||
(RegisterResponse r) -> {
|
||||
deviceId = r.getDeviceId();
|
||||
ui.appendLog("✅ 注册成功: deviceId=" + deviceId + "(" + r.getMessage() + ")");
|
||||
ui.appendLog(" 下一步:输入用户 ID 与手机号后点击「绑定用户」");
|
||||
ui.setCurrentStep(2); // 进入第2步:绑定
|
||||
refreshStatus();
|
||||
ui.appendLog("—— ① 设备注册(PoP 验签)——");
|
||||
runStep("设备注册", registerDeviceWithPop(), (RegisterResponse r) -> {
|
||||
deviceId = r.getDeviceId();
|
||||
ui.appendLog("✅ 注册成功: deviceId=" + deviceId + "(" + r.getMessage() + ")");
|
||||
ui.appendLog(" 下一步:输入用户 ID 与手机号后点击「绑定用户」");
|
||||
ui.setCurrentStep(2); // 进入第2步:绑定
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造带 PoP 签名的设备注册请求链:
|
||||
* 1. GET /api/device/challenge 获取一次性挑战值
|
||||
* 2. 用 TEE 私钥对 challenge 签名
|
||||
* 3. POST /api/device/register 携带 {sn, publicKeyBase64, challenge, signature}
|
||||
* 4. 解包统一响应,返回业务数据 RegisterResponse
|
||||
*
|
||||
* 供首次注册与恢复出厂后重新注册复用。
|
||||
*/
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -311,17 +334,25 @@ public class MainViewModel extends BaseViewModel {
|
||||
ui.postToast("请先完成「设备注册」");
|
||||
return;
|
||||
}
|
||||
if (!requireCrypto()) return;
|
||||
this.userId = userId;
|
||||
this.phone = phone;
|
||||
ApiClient.saveUserId(userId); // 后续接口自动携带 X-User-Id
|
||||
ui.appendLog("—— ② 绑定用户 ——");
|
||||
ui.appendLog("—— ② 绑定用户(设备签名认证)——");
|
||||
runStep("绑定用户",
|
||||
ApiClient.deviceApi().bindDevice(new BindRequest(userId, sn, phone))
|
||||
.map(ApiResponse::getData),
|
||||
// 取 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)),
|
||||
(MessageResponse r) -> {
|
||||
bound = true;
|
||||
ui.appendLog("✅ 绑定成功: " + userId + " ↔ " + sn
|
||||
+ "(已保存身份,后续接口自动携带 X-User-Id)");
|
||||
+ "(设备签名认证通过)");
|
||||
ui.appendLog(" 下一步:点击「选择并上传图片」");
|
||||
ui.setCurrentStep(3); // 进入第3步:上传
|
||||
refreshStatus();
|
||||
@@ -360,6 +391,7 @@ public class MainViewModel extends BaseViewModel {
|
||||
+ "(服务端已用设备公钥验签 + UK 信封加密 DEK)");
|
||||
ui.appendLog(" 下一步:点击「下载解密照片」");
|
||||
ui.setCurrentStep(4); // 进入第4步:下载
|
||||
refreshStatus(); // 刷新状态摘要(更新最近照片)
|
||||
})
|
||||
.doFinally(() -> ui.setBusy(false))
|
||||
.subscribe(
|
||||
@@ -393,12 +425,22 @@ public class MainViewModel extends BaseViewModel {
|
||||
lastPhotoId = photoId;
|
||||
lastCiphertextBase64 = p.ciphertextBase64;
|
||||
lastIvBase64 = p.ivBase64;
|
||||
// 不再缓存明文 DEK(仅内存态加密产物):
|
||||
// lastDekBase64 = p.dekBase64;
|
||||
|
||||
ui.appendLog(" 图片加密: photoId=" + photoId
|
||||
+ ",大小=" + imageBytes.length + " 字节,DEK 已生成");
|
||||
+ ",大小=" + 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);
|
||||
|
||||
UploadPhotoRequest req = new UploadPhotoRequest(sn, photoId,
|
||||
p.ciphertextBase64, p.ivBase64, p.dekBase64, sig, metadata);
|
||||
p.ciphertextBase64, p.ivBase64, encryptedDek, sig, metadata);
|
||||
return ApiClient.deviceApi().uploadPhoto(req)
|
||||
.map(ApiResponse::getData); // 解包统一响应,返回业务数据
|
||||
}
|
||||
@@ -406,40 +448,45 @@ public class MainViewModel extends BaseViewModel {
|
||||
// ==================== ④ 下载解密照片 ====================
|
||||
|
||||
/**
|
||||
* 用户端下载并解密最近上传的照片(返回明文 Base64)。
|
||||
* 设备本地下载解密最近一张照片(端到端加密,零知识服务端)。
|
||||
*
|
||||
* 不再依赖内存态 lastPhotoId(App 重启会丢失),而是:
|
||||
* ① 先调 GET /api/user/photos 从后端获取该用户的照片 ID 列表;
|
||||
* ① 先调 POST /api/device/photos(设备签名认证)获取该设备绑定用户的照片 ID 列表;
|
||||
* ② 取最近一张(列表末尾);
|
||||
* ③ 再调 GET /api/photo/{id}/decrypt 下载并解密。
|
||||
* ③ 调 POST /api/device/photo/{id}/local 服务端下发密文+加密DEK,设备在 TEE 内本地解密。
|
||||
*
|
||||
* 真实场景:用户在其他设备上登录,下载并查看自己设备的加密照片。
|
||||
* 全程设备签名认证(SN + challenge + signature),服务端不接触明文照片。
|
||||
*/
|
||||
public void downloadLastPhoto() {
|
||||
ui.appendLog("—— ④ 用户下载解密照片 ——");
|
||||
String userId = ApiClient.getUserId();
|
||||
if (!requireCrypto()) return;
|
||||
if (sn == null) {
|
||||
ui.postToast("设备尚未初始化");
|
||||
return;
|
||||
}
|
||||
ui.appendLog("—— ④ 设备本地下载解密最近一张照片 ——");
|
||||
runStep("下载解密",
|
||||
ApiClient.deviceApi().userPhotos(userId)
|
||||
.map(ApiResponse::getData)
|
||||
// 1. 设备签名认证拉取本设备照片列表
|
||||
deviceSignedAuthFlatMap(auth -> ApiClient.deviceApi().devicePhotos(auth)
|
||||
.map(ApiResponse::getData))
|
||||
.flatMap(photos -> {
|
||||
List<String> ids = photos.getPhotos();
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return Single.error(new IllegalStateException("后端无照片记录,请先「选择并上传图片」"));
|
||||
}
|
||||
// 取最近一张(列表按上传时间升序,末尾即最新)
|
||||
// 2. 取最近一张(列表按上传时间升序,末尾即最新),设备本地解密
|
||||
String latestId = ids.get(ids.size() - 1);
|
||||
return ApiClient.deviceApi()
|
||||
.downloadAndDecrypt(latestId, userId)
|
||||
.map(ApiResponse::getData);
|
||||
return Single.fromCallable(() -> {
|
||||
byte[] plain = decryptPhotoLocal(latestId);
|
||||
return new Object[]{latestId, plain};
|
||||
})
|
||||
.subscribeOn(Schedulers.computation());
|
||||
}),
|
||||
(DownloadDecryptResponse d) -> {
|
||||
ui.setDecryptedImageBase64(d.getPlaintextBase64()); // 解密后的图片明文(Base64)→ 预览显示
|
||||
// 注意:不要在日志里打印整段明文(大图会导致 TextView 巨量重绘而 ANR),
|
||||
// 也不在主线程二次 Base64 解码(估算字节数即可)。
|
||||
String b64 = d.getPlaintextBase64();
|
||||
int approxBytes = b64.length() / 4 * 3;
|
||||
ui.appendLog("✅ 下载解密成功(" + d.getPhotoId() + "),明文约 " + approxBytes
|
||||
+ " 字节(已显示到预览区,服务端从后端照片列表取最近一张,信封解密)");
|
||||
(Object[] r) -> {
|
||||
String pid = (String) r[0];
|
||||
byte[] plain = (byte[]) r[1];
|
||||
ui.setDecryptedImageBytes(plain); // 明文二进制 → 预览显示
|
||||
ui.appendLog("✅ 设备本地解密成功(" + pid + "),明文 " + plain.length
|
||||
+ " 字节(已显示到预览区,服务端未接触明文)");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -480,18 +527,15 @@ public class MainViewModel extends BaseViewModel {
|
||||
if (deviceId != null) {
|
||||
ui.appendLog("当前已注册 deviceId=" + deviceId + ",仍将用最新公钥重新注册(后端幂等覆盖)");
|
||||
}
|
||||
ui.appendLog("—— ⑥ 重新注册(新公钥)——");
|
||||
runStep("重新注册",
|
||||
ApiClient.deviceApi().registerDevice(new RegisterRequest(sn, pubKey))
|
||||
.map(ApiResponse::getData),
|
||||
(RegisterResponse r) -> {
|
||||
deviceId = r.getDeviceId();
|
||||
ui.appendLog("✅ 重新注册成功: newDeviceId=" + deviceId
|
||||
+ "(旧设备已停用,SN 归属保留)");
|
||||
ui.appendLog(" 下一步:点击「发送短信验证码」");
|
||||
ui.setCurrentStep(7); // 进入第7步:短信
|
||||
refreshStatus();
|
||||
});
|
||||
ui.appendLog("—— ⑥ 重新注册(新公钥,PoP 验签)——");
|
||||
runStep("重新注册", registerDeviceWithPop(), (RegisterResponse r) -> {
|
||||
deviceId = r.getDeviceId();
|
||||
ui.appendLog("✅ 重新注册成功: newDeviceId=" + deviceId
|
||||
+ "(旧设备已停用,SN 归属保留)");
|
||||
ui.appendLog(" 下一步:点击「发送短信验证码」");
|
||||
ui.setCurrentStep(7); // 进入第7步:短信
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑦ 发送短信验证码 ====================
|
||||
@@ -505,12 +549,20 @@ public class MainViewModel extends BaseViewModel {
|
||||
ui.postToast("请先完成「重新注册」");
|
||||
return;
|
||||
}
|
||||
if (!requireCrypto()) return;
|
||||
this.phone = phone;
|
||||
ApiClient.saveUserId(userId); // 短信接口需要登录态(X-User-Id)
|
||||
ui.appendLog("—— ⑦ 发送短信验证码 → " + phone + " ——");
|
||||
ui.appendLog("—— ⑦ 发送短信验证码 → " + phone + "(设备签名认证)——");
|
||||
runStep("发送短信",
|
||||
ApiClient.deviceApi().sendSms(new SmsRequest(phone))
|
||||
.map(ApiResponse::getData),
|
||||
// 取 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)),
|
||||
(MessageResponse s) -> {
|
||||
ui.appendLog("✅ 短信已发送: " + s.getMessage()
|
||||
+ "(demo 后端固定验证码 000000,见后端控制台)");
|
||||
@@ -533,19 +585,33 @@ public class MainViewModel extends BaseViewModel {
|
||||
return;
|
||||
}
|
||||
ui.appendLog("—— ⑧ 恢复授权(短信 + SN 归属双因子)——");
|
||||
// 在 flatMap 作用域内暂存服务端返回的新设备 id(响应对象 r 在成功回调不可见)
|
||||
final String[] newDeviceId = new String[1];
|
||||
runStep("恢复授权",
|
||||
ApiClient.deviceApi()
|
||||
.recoverDevice(new RecoverRequest(userId, sn, smsCode, pubKey))
|
||||
// 取 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))
|
||||
.flatMap(r -> Single.fromCallable(() -> {
|
||||
newDeviceId[0] = r.getData().getDeviceId();
|
||||
byte[] tokenBytes = Base64.getDecoder()
|
||||
.decode(r.getData().getEncryptedRecoveryToken());
|
||||
return crypto.decryptRecoveryToken(tokenBytes); // TEE 私钥解密
|
||||
}).subscribeOn(Schedulers.computation())),
|
||||
(String token) -> {
|
||||
// 关键:恢复出厂后服务端生成的是「新设备」deviceId,必须同步更新本地,
|
||||
// 否则下一步 recoverPhotos 会带「旧 deviceId」请求,导致 token 内 deviceId 校验不一致 → 403
|
||||
deviceId = newDeviceId[0];
|
||||
recoveryToken = token;
|
||||
bound = true; // 恢复授权通过即视为已重新绑定
|
||||
ui.appendLog("✅ 恢复授权成功,TEE 解密 Recovery Token: " + token
|
||||
+ "(nonce 一次性 + 5 分钟窗口)");
|
||||
ui.appendLog(" 本地 deviceId 已更新为新设备: " + deviceId);
|
||||
ui.appendLog(" 下一步:点击「恢复照片」");
|
||||
ui.setCurrentStep(9); // 进入第9步:恢复照片
|
||||
refreshStatus();
|
||||
@@ -565,18 +631,251 @@ public class MainViewModel extends BaseViewModel {
|
||||
ui.postToast("请先完成「重新注册」与「恢复授权」");
|
||||
return;
|
||||
}
|
||||
// 防重入:recoveryToken 一次性,重复点击会撞服务端 nonce 重放(403)。
|
||||
// 用本地快照,进入即置空,确保即便并发也只发一次请求。
|
||||
final String tokenSnapshot = recoveryToken;
|
||||
recoveryToken = null;
|
||||
ui.appendLog("—— ⑨ 恢复照片(取回 DEK 并还原)——");
|
||||
runStep("恢复照片",
|
||||
ApiClient.deviceApi()
|
||||
.recoverPhotos(new PhotoRecoverRequest(deviceId, recoveryToken, userId))
|
||||
.recoverPhotos(new PhotoRecoverRequest(deviceId, tokenSnapshot, userId))
|
||||
.flatMap(pr -> Single.fromCallable(() -> restoreFromDeks(pr.getData()))
|
||||
.subscribeOn(Schedulers.computation())),
|
||||
(String result) -> ui.appendLog(result));
|
||||
(String result) -> {
|
||||
ui.appendLog(result);
|
||||
ui.appendLog(" Recovery Token 已一次性消费,如需再次恢复请重新「恢复授权」");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ④b 设备本地下载解密最近一张照片(流式语义,端到端加密)。
|
||||
*
|
||||
* 与 {@link #downloadLastPhoto()} 相同的设备签名 + 本地解密路径,但以显式 Disposable 编排
|
||||
* 并控制 busy 状态:设备签名认证拉取照片列表 → 取最近一张 → 设备在 TEE 内本地解密。
|
||||
* 服务端仅下发密文 + 设备公钥加密的 DEK + IV,不接触明文照片。
|
||||
*/
|
||||
public void downloadLastPhotoStreaming() {
|
||||
if (!requireCrypto()) return;
|
||||
if (sn == null) {
|
||||
ui.postToast("设备尚未初始化");
|
||||
return;
|
||||
}
|
||||
ui.appendLog("—— ④b 设备本地下载解密最近一张照片 ——");
|
||||
ui.setBusy(true);
|
||||
Disposable d = deviceSignedAuthFlatMap(auth -> ApiClient.deviceApi().devicePhotos(auth)
|
||||
.map(ApiResponse::getData))
|
||||
.flatMap(photos -> {
|
||||
List<String> ids = photos.getPhotos();
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return Single.error(new IllegalStateException("后端无照片记录,请先「选择并上传图片」"));
|
||||
}
|
||||
String latestId = ids.get(ids.size() - 1);
|
||||
// 设备本地解密(TEE 内解密 DEK → AES-GCM 解密密文)
|
||||
return Single.fromCallable(() -> {
|
||||
byte[] plain = decryptPhotoLocal(latestId);
|
||||
return new Object[]{latestId, plain};
|
||||
})
|
||||
.subscribeOn(Schedulers.computation());
|
||||
})
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
r -> {
|
||||
String pid = (String) r[0];
|
||||
byte[] plain = (byte[]) r[1];
|
||||
ui.setDecryptedImageBytes(plain); // 二进制明文 → 预览显示
|
||||
ui.appendLog("✅ 设备本地解密成功(" + pid + "),明文 " + plain.length
|
||||
+ " 字节(服务端未接触明文)");
|
||||
ui.setBusy(false);
|
||||
},
|
||||
e -> {
|
||||
Log.e(TAG, "本地下载解密失败: " + e.getMessage(), e);
|
||||
ui.appendLog("❌ 本地下载解密失败: " + e.getMessage());
|
||||
ui.postToast("本地下载解密失败: " + e.getMessage());
|
||||
ui.setBusy(false);
|
||||
}
|
||||
);
|
||||
addDisposable(d);
|
||||
}
|
||||
|
||||
// ==================== ⑥b 列出全部照片元数据(供展示) ====================
|
||||
|
||||
/**
|
||||
* 拉取当前用户全部照片的元数据(photoId、上传时间、来源设备、是否可设备端解密),
|
||||
* 仅展示列表,不含密文 / DEK。真实场景:设备端「我的照片」画廊列表。
|
||||
*/
|
||||
public void listAllPhotos() {
|
||||
if (!requireCrypto()) return;
|
||||
if (sn == null) {
|
||||
ui.postToast("设备尚未初始化");
|
||||
return;
|
||||
}
|
||||
ui.appendLog("—— 列出全部照片元数据(设备签名认证)——");
|
||||
runStep("列出照片",
|
||||
deviceSignedAuthFlatMap(auth -> ApiClient.deviceApi().devicePhotosMetadata(auth)
|
||||
.map(ApiResponse::getData)),
|
||||
(PhotoMetadataResponse r) -> {
|
||||
if (r.getPhotos() == null || r.getPhotos().isEmpty()) {
|
||||
ui.appendLog(" 暂无照片,请先在设备端「选择并上传图片」");
|
||||
return;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("✅ 共 " + r.getPhotos().size() + " 张照片:\n");
|
||||
for (PhotoMetadataResponse.PhotoMeta p : r.getPhotos()) {
|
||||
sb.append(" • ").append(p.getPhotoId());
|
||||
if (p.getSn() != null && !p.getSn().isBlank()) {
|
||||
sb.append(" [设备 ").append(p.getSn()).append("]");
|
||||
}
|
||||
sb.append(" [上传 ").append(formatTime(p.getUploadTime())).append("]");
|
||||
sb.append(p.isActiveDevice() ? " [可设备端解密]" : " [设备已停用,无法本地解密]");
|
||||
sb.append("\n");
|
||||
}
|
||||
ui.appendLog(sb.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑥c 设备本地下载解密全部照片(端到端加密) ====================
|
||||
|
||||
/**
|
||||
* 设备端本地解密全部照片(端到端加密,零知识服务端)。
|
||||
*
|
||||
* 流程:
|
||||
* 1. GET /api/user/photos/metadata 拉取该用户全部照片元数据;
|
||||
* 2. 逐张调用 POST /api/device/photo/{photoId}/local(携带 SN + TEE 签名 Challenge-Response 认证);
|
||||
* 3. 服务端下发「密文 + 设备公钥加密的 DEK + IV」,服务端全程不接触明文;
|
||||
* 4. 设备在 TEE 内用私钥解密 DEK,再 AES-GCM 解密密文,得到明文并渲染到预览区。
|
||||
*
|
||||
* 仅对本设备所属用户且仍处于 active 的照片执行本地解密(其余跳过)。
|
||||
*/
|
||||
public void downloadAllPhotosLocal() {
|
||||
if (!requireCrypto()) return;
|
||||
if (sn == null) {
|
||||
ui.postToast("设备尚未初始化");
|
||||
return;
|
||||
}
|
||||
ui.appendLog("—— 设备本地下载解密全部照片(端到端加密,服务端不见明文)——");
|
||||
ui.setBusy(true);
|
||||
Disposable d = deviceSignedAuthFlatMap(auth -> ApiClient.deviceApi().devicePhotos(auth)
|
||||
.map(ApiResponse::getData))
|
||||
// 2. 逐张本地解密
|
||||
.flatMap(photos -> Single.fromCallable(() -> {
|
||||
List<String> ids = photos.getPhotos();
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return "暂无照片,请先在设备端「选择并上传图片」";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("✅ 本设备可本地解密 " + ids.size() + " 张照片:\n");
|
||||
int ok = 0;
|
||||
int fail = 0;
|
||||
byte[] lastPlain = null;
|
||||
String lastPhotoId = null;
|
||||
for (String photoId : ids) {
|
||||
try {
|
||||
byte[] plain = decryptPhotoLocal(photoId);
|
||||
ok++;
|
||||
sb.append(" • ").append(photoId).append(": 本地解密成功,明文 ")
|
||||
.append(plain.length).append(" 字节\n");
|
||||
lastPlain = plain;
|
||||
lastPhotoId = photoId;
|
||||
} catch (Exception e) {
|
||||
fail++;
|
||||
sb.append(" • ").append(photoId).append(": 本地解密失败 — ")
|
||||
.append(e.getMessage()).append("\n");
|
||||
}
|
||||
}
|
||||
// 最后一张成功的照片渲染到预览区(避免大图循环刷新 ANR)
|
||||
if (lastPlain != null) {
|
||||
ui.setDecryptedImageBytes(lastPlain);
|
||||
sb.append("已渲染最近一张成功解密(").append(lastPhotoId)
|
||||
.append(")到预览区\n");
|
||||
}
|
||||
sb.append("成功 ").append(ok).append(" 张,失败/跳过 ").append(fail).append(" 张");
|
||||
return sb.toString();
|
||||
}).subscribeOn(Schedulers.computation()))
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
result -> {
|
||||
ui.appendLog(result);
|
||||
ui.setBusy(false);
|
||||
},
|
||||
e -> {
|
||||
Log.e(TAG, "本地下载解密失败: " + e.getMessage(), e);
|
||||
ui.appendLog("❌ 本地下载解密失败: " + e.getMessage());
|
||||
ui.postToast("本地下载解密失败: " + e.getMessage());
|
||||
ui.setBusy(false);
|
||||
}
|
||||
);
|
||||
addDisposable(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造设备签名认证请求体(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 → 本地解密。
|
||||
*
|
||||
* @return 明文照片字节
|
||||
*/
|
||||
private byte[] decryptPhotoLocal(String photoId) {
|
||||
// 1. 设备签名认证(Challenge-Response:证明持有该 SN 设备私钥)
|
||||
DeviceStatusRequest auth = deviceSignedAuth();
|
||||
|
||||
// 2. 调用设备本地解密接口,下发密文 + 设备公钥加密的 DEK + IV
|
||||
DevicePhotoResponse resp = ApiClient.deviceApi()
|
||||
.deviceDownloadLocal(photoId, auth)
|
||||
.map(ApiResponse::getData)
|
||||
.blockingGet();
|
||||
|
||||
// 3. TEE 私钥解密 DEK → AES-GCM 解密密文 → 明文
|
||||
byte[] dekBytes = crypto.decryptDek(resp.getEncryptedDekBase64());
|
||||
return crypto.decryptPhoto(resp.getCiphertextBase64(), resp.getIvBase64(), dekBytes);
|
||||
}
|
||||
|
||||
/** 时间戳(毫秒)→ "yyyy-MM-dd HH:mm" 展示用 */
|
||||
private String formatTime(long millis) {
|
||||
if (millis <= 0) return "-";
|
||||
try {
|
||||
java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm",
|
||||
java.util.Locale.getDefault());
|
||||
return sdf.format(new java.util.Date(millis));
|
||||
} catch (Exception e) {
|
||||
return String.valueOf(millis);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 DEK 列表还原最近上传的照片(返回日志文案)。
|
||||
* 密文沿用本地上传缓存(真实场景中密文文件从云端下载,这里聚焦 DEK 还原链路)。
|
||||
*
|
||||
* 服务端 recoverPhotos 已连同 DEK 一并下发每条记录的密文(ciphertextBase64)与 IV(ivBase64),
|
||||
* 设备侧在 TEE 内用私钥解密 DEK,再 AES-GCM 解密密文得到明文照片,并直接渲染到预览区,
|
||||
* 完整复现「零知识服务端只存密文、密钥在设备端」的还原链路。
|
||||
*/
|
||||
private String restoreFromDeks(PhotoRecoverResponse pr) {
|
||||
List<EncryptedDek> deks = pr.getDeks();
|
||||
@@ -587,14 +886,24 @@ public class MainViewModel extends BaseViewModel {
|
||||
}
|
||||
// 匹配本次链路最新上传的 photoId,否则用旧照片 DEK 解新照片密文会 GCM 校验失败。
|
||||
EncryptedDek target = matchDek(deks, lastPhotoId);
|
||||
byte[] dekBytes = crypto.decryptDek(target.getEncryptedDekBase64()); // TEE 解 DEK
|
||||
if (lastCiphertextBase64 == null) {
|
||||
return sb.append(" 本地无密文缓存,跳过内容还原").toString();
|
||||
|
||||
// 1. TEE 私钥解密 DEK
|
||||
byte[] dekBytes = crypto.decryptDek(target.getEncryptedDekBase64());
|
||||
|
||||
// 2. AES-GCM 解密密文(服务端下发的 ciphertextBase64 + ivBase64)
|
||||
String cipher = target.getCiphertextBase64();
|
||||
String iv = target.getIvBase64();
|
||||
if (cipher == null || iv == null) {
|
||||
return sb.append(" 该记录缺密文/IV,跳过内容还原").toString();
|
||||
}
|
||||
byte[] photo = crypto.decryptPhoto(lastCiphertextBase64, lastIvBase64, dekBytes); // AES-GCM 解照片
|
||||
byte[] photo = crypto.decryptPhoto(cipher, iv, dekBytes);
|
||||
|
||||
// 3. 明文渲染到预览区(二进制字节,无需 Base64)
|
||||
ui.setDecryptedImageBytes(photo);
|
||||
|
||||
// 只打印明文大小,不打印整段内容(大图会导致日志 TextView 巨量重绘而 ANR)
|
||||
sb.append(" TEE 解密 DEK(").append(target.getPhotoId()).append(") → 还原成功,明文大小 ")
|
||||
.append(photo.length).append(" 字节");
|
||||
sb.append(" 还原照片(").append(target.getPhotoId()).append(") 成功,明文大小 ")
|
||||
.append(photo.length).append(" 字节,已渲染到预览区");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@@ -614,6 +923,8 @@ public class MainViewModel extends BaseViewModel {
|
||||
|
||||
private void refreshStatus() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Integer stepVal = ui.getCurrentStep().getValue();
|
||||
sb.append("流程进度: ").append(stepLabel(stepVal != null ? stepVal : 0)).append("\n");
|
||||
sb.append("设备 SN: ").append(sn == null ? "-" : sn).append("\n");
|
||||
sb.append("公钥(前24): ").append(pubKey == null ? "-" : pubKey.substring(0, Math.min(24, pubKey.length()))).append("...\n");
|
||||
sb.append("注册状态: ").append(deviceId == null ? "未注册" : "deviceId=" + deviceId).append("\n");
|
||||
@@ -628,6 +939,22 @@ public class MainViewModel extends BaseViewModel {
|
||||
ui.setBound(bound);
|
||||
}
|
||||
|
||||
/** 流程步骤文案(与界面按钮 ①-⑨ 对应),用于状态摘要展示当前所处步骤 */
|
||||
private String stepLabel(int step) {
|
||||
switch (step) {
|
||||
case 1: return "① 设备注册";
|
||||
case 2: return "② 绑定用户";
|
||||
case 3: return "③ 上传图片";
|
||||
case 4: return "④ 下载解密";
|
||||
case 5: return "⑤ 恢复出厂";
|
||||
case 6: return "⑥ 重新注册";
|
||||
case 7: return "⑦ 短信验证码";
|
||||
case 8: return "⑧ 恢复授权";
|
||||
case 9: return "⑨ 恢复照片";
|
||||
default: return "设备初始化中";
|
||||
}
|
||||
}
|
||||
|
||||
/** 设备未就绪时给出提示并返回 false,避免抛异常导致界面假死 */
|
||||
private boolean requireCrypto() {
|
||||
if (crypto == null) {
|
||||
|
||||
@@ -59,6 +59,9 @@ public class UiState {
|
||||
/** 下载解密后的图片明文(Base64),供预览显示 */
|
||||
private final MutableLiveData<String> decryptedImageBase64 = new MutableLiveData<>();
|
||||
|
||||
/** 下载解密后的图片明文(二进制字节),流式下载场景直接承载原始明文,无需 Base64 */
|
||||
private final MutableLiveData<byte[]> decryptedImageBytes = new MutableLiveData<>();
|
||||
|
||||
/** 当前设备/用户状态摘要(SN、公钥、注册/绑定/恢复进度) */
|
||||
private final MutableLiveData<String> status = new MutableLiveData<>("");
|
||||
|
||||
@@ -109,6 +112,14 @@ public class UiState {
|
||||
decryptedImageBase64.postValue(base64);
|
||||
}
|
||||
|
||||
public LiveData<byte[]> getDecryptedImageBytes() {
|
||||
return decryptedImageBytes;
|
||||
}
|
||||
|
||||
public void setDecryptedImageBytes(byte[] bytes) {
|
||||
decryptedImageBytes.postValue(bytes);
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status.postValue(status);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* 认证拦截器:从本地偏好读取用户 ID,自动注入 X-User-Id 请求头。
|
||||
* 对齐 WebRTCController 的 AuthInterceptor 模式(Token 注入);
|
||||
* 本 demo 后端以 X-User-Id 做照片归属鉴权。
|
||||
* 认证拦截器(零信任版本)。
|
||||
*
|
||||
* 设备端(Android)一律走「设备签名认证」(SN + challenge + signature),由各接口在请求体内
|
||||
* 携带签名;后端已移除对 X-User-Id 的信任,因此本拦截器**不再注入任何自报身份头**。
|
||||
* 此处仅保留本地 userId 的持久化存取,供 UI 展示 / 日志使用,不参与任何鉴权。
|
||||
*/
|
||||
public class AuthInterceptor implements Interceptor {
|
||||
|
||||
@@ -27,12 +29,8 @@ public class AuthInterceptor implements Interceptor {
|
||||
|
||||
@Override
|
||||
public Response intercept(Chain chain) throws IOException {
|
||||
Request.Builder builder = chain.request().newBuilder();
|
||||
String userId = getUserId();
|
||||
if (userId != null && !userId.isEmpty()) {
|
||||
builder.header("X-User-Id", userId);
|
||||
}
|
||||
return chain.proceed(builder.build());
|
||||
// 零信任:不注入 X-User-Id / 任何自报身份头,设备身份一律由请求体签名认证证明。
|
||||
return chain.proceed(chain.request());
|
||||
}
|
||||
|
||||
public void saveUserId(String userId) {
|
||||
|
||||
@@ -7,7 +7,9 @@ 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;
|
||||
import com.secure.demo.network.model.MessageResponse;
|
||||
import com.secure.demo.network.model.PhotoMetadataResponse;
|
||||
import com.secure.demo.network.model.PhotoRecoverRequest;
|
||||
import com.secure.demo.network.model.PhotoRecoverResponse;
|
||||
import com.secure.demo.network.model.RecoverRequest;
|
||||
@@ -20,12 +22,14 @@ import com.secure.demo.network.model.UploadPhotoResponse;
|
||||
import com.secure.demo.network.model.UserPhotosResponse;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import okhttp3.ResponseBody;
|
||||
import retrofit2.http.Body;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Header;
|
||||
import retrofit2.http.POST;
|
||||
import retrofit2.http.Path;
|
||||
import retrofit2.http.Query;
|
||||
import retrofit2.http.Streaming;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 设备安全后端 API 接口定义。
|
||||
@@ -48,11 +52,11 @@ public interface DeviceApi {
|
||||
@POST("api/device/register")
|
||||
Single<ApiResponse<RegisterResponse>> registerDevice(@Body RegisterRequest body);
|
||||
|
||||
/** 2. 用户绑定设备(用户端,Bearer Token / X-User-Id 鉴权) */
|
||||
/** 2. 绑定设备(零信任:设备签名认证,sn + challenge + signature + userId) */
|
||||
@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);
|
||||
|
||||
@@ -60,7 +64,7 @@ public interface DeviceApi {
|
||||
@POST("api/photo/upload")
|
||||
Single<ApiResponse<UploadPhotoResponse>> uploadPhoto(@Body UploadPhotoRequest body);
|
||||
|
||||
/** 5. 恢复设备(短信验证 + 新公钥,换机/恢复出厂后调用,用户端) */
|
||||
/** 5. 恢复设备(零信任:设备签名认证,短信验证 + 新公钥,换机/恢复出厂后调用) */
|
||||
@POST("api/device/recover")
|
||||
Single<ApiResponse<RecoverResponse>> recoverDevice(@Body RecoverRequest body);
|
||||
|
||||
@@ -68,14 +72,38 @@ public interface DeviceApi {
|
||||
@POST("api/photo/recover")
|
||||
Single<ApiResponse<PhotoRecoverResponse>> recoverPhotos(@Body PhotoRecoverRequest body);
|
||||
|
||||
/** 7. 用户下载并解密照片(Bearer Token / X-User-Id 鉴权,返回明文照片 Base64 的 JSON) */
|
||||
@GET("api/photo/{photoId}/decrypt")
|
||||
Single<ApiResponse<DownloadDecryptResponse>> downloadAndDecrypt(@Path("photoId") String photoId,
|
||||
@Header("X-User-Id") String userId);
|
||||
/**
|
||||
* 6b. 设备本地下载解密单张照片(端到端加密,设备端 Challenge-Response 签名认证)。
|
||||
* 服务端下发「密文 + 设备公钥加密的 DEK + IV」,设备在 TEE 内本地解密,服务端不接触明文。
|
||||
*/
|
||||
@POST("api/device/photo/{photoId}/local")
|
||||
Single<ApiResponse<DevicePhotoResponse>> deviceDownloadLocal(@Path("photoId") String photoId,
|
||||
@Body DeviceStatusRequest body);
|
||||
|
||||
/** 7b. 用户照片列表(用户端,X-User-Id 鉴权,返回 photoId 列表) */
|
||||
@GET("api/user/photos")
|
||||
Single<ApiResponse<UserPhotosResponse>> userPhotos(@Header("X-User-Id") String userId);
|
||||
/** 7. 用户下载并解密照片(Web 用户端,Bearer Token 鉴权,返回明文照片 Base64 的 JSON) */
|
||||
@GET("api/photo/{photoId}/decrypt")
|
||||
Single<ApiResponse<DownloadDecryptResponse>> downloadAndDecrypt(@Path("photoId") String photoId);
|
||||
|
||||
/**
|
||||
* 7b. 用户下载并解密照片(Web 用户端,Bearer Token 鉴权,流式版)。
|
||||
* 后端以 {@code StreamingResponseBody} 直接写出原始二进制明文,不经过 JSON/Base64 包装,
|
||||
* 边解密边传输,内存占用与照片大小无关。配合 {@code @Streaming} 让 Retrofit 不缓冲整段响应。
|
||||
*/
|
||||
@Streaming
|
||||
@GET("api/photo/{photoId}/decrypt/stream")
|
||||
Single<ResponseBody> downloadAndDecryptStreaming(@Path("photoId") String photoId);
|
||||
|
||||
/** 7b. 设备照片 ID 列表(零信任:设备签名认证,返回该设备绑定用户的照片列表) */
|
||||
@POST("api/device/photos")
|
||||
Single<ApiResponse<UserPhotosResponse>> devicePhotos(@Body DeviceStatusRequest body);
|
||||
|
||||
/** 8. 设备照片元数据列表(零信任:设备签名认证,含上传时间/来源设备/是否可设备端解密,供展示用) */
|
||||
@POST("api/device/photos/metadata")
|
||||
Single<ApiResponse<PhotoMetadataResponse>> devicePhotosMetadata(@Body DeviceStatusRequest body);
|
||||
|
||||
/** 9. 获取服务端传输公钥(设备端用它加密上传的 DEK,使 DEK 在网络上不明文) */
|
||||
@GET("api/device/transport-key")
|
||||
Single<ApiResponse<Map<String, String>>> transportPublicKey();
|
||||
|
||||
/** 8. 健康检查(公开) */
|
||||
@GET("api/health")
|
||||
|
||||
@@ -2,33 +2,42 @@ package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 用户绑定设备请求:{ userId, sn, phone }(phone 可选,未传则由服务端使用默认值) */
|
||||
/**
|
||||
* 绑定设备请求(零信任:设备签名认证)。
|
||||
* { sn, challenge, signature, userId, phone }
|
||||
*
|
||||
* - sn / challenge / signature:Challenge-Response 设备签名认证,服务端用该 SN 对应设备公钥验签
|
||||
* - userId:绑定目标用户
|
||||
* - phone:可选,未传由服务端使用默认值
|
||||
*/
|
||||
public class BindRequest {
|
||||
|
||||
@SerializedName("userId")
|
||||
private final String userId;
|
||||
|
||||
@SerializedName("sn")
|
||||
private final String sn;
|
||||
private String sn;
|
||||
|
||||
@SerializedName("challenge")
|
||||
private String challenge;
|
||||
|
||||
@SerializedName("signature")
|
||||
private String signature;
|
||||
|
||||
@SerializedName("userId")
|
||||
private String userId;
|
||||
|
||||
@SerializedName("phone")
|
||||
private final String phone;
|
||||
private String phone;
|
||||
|
||||
public BindRequest(String userId, String sn, String phone) {
|
||||
this.userId = userId;
|
||||
public BindRequest(String sn, String challenge, String signature, String userId, String phone) {
|
||||
this.sn = sn;
|
||||
this.challenge = challenge;
|
||||
this.signature = signature;
|
||||
this.userId = userId;
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return 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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 设备本地下载解密单张照片响应(端到端加密,设备 TEE 本地解密)。
|
||||
*
|
||||
* 对应后端 POST /api/device/photo/{photoId}/local 的 data 字段:
|
||||
* { photoId, encryptedDekBase64, ciphertextBase64, ivBase64 }
|
||||
*
|
||||
* - encryptedDekBase64:DEK 被设备公钥加密(RSA-OAEP),仅设备私钥可解
|
||||
* - ciphertextBase64:照片密文(AES-256-GCM)
|
||||
* - ivBase64:GCM IV
|
||||
* 设备端在 TEE 内用私钥解密 DEK,再 AES-GCM 解密密文,本地还原明文。
|
||||
*/
|
||||
public class DevicePhotoResponse {
|
||||
|
||||
@SerializedName("photoId")
|
||||
private String photoId;
|
||||
|
||||
@SerializedName("encryptedDekBase64")
|
||||
private String encryptedDekBase64;
|
||||
|
||||
@SerializedName("ciphertextBase64")
|
||||
private String ciphertextBase64;
|
||||
|
||||
@SerializedName("ivBase64")
|
||||
private String ivBase64;
|
||||
|
||||
public String getPhotoId() { return photoId; }
|
||||
public void setPhotoId(String photoId) { this.photoId = photoId; }
|
||||
|
||||
public String getEncryptedDekBase64() { return encryptedDekBase64; }
|
||||
public void setEncryptedDekBase64(String encryptedDekBase64) { this.encryptedDekBase64 = encryptedDekBase64; }
|
||||
|
||||
public String getCiphertextBase64() { return ciphertextBase64; }
|
||||
public void setCiphertextBase64(String ciphertextBase64) { this.ciphertextBase64 = ciphertextBase64; }
|
||||
|
||||
public String getIvBase64() { return ivBase64; }
|
||||
public void setIvBase64(String ivBase64) { this.ivBase64 = ivBase64; }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** DEK 条目:{ photoId, encryptedDekBase64 } */
|
||||
/** DEK 条目:{ photoId, encryptedDekBase64, ciphertextBase64, ivBase64 } */
|
||||
public class EncryptedDek {
|
||||
|
||||
@SerializedName("photoId")
|
||||
@@ -11,6 +11,12 @@ public class EncryptedDek {
|
||||
@SerializedName("encryptedDekBase64")
|
||||
private String encryptedDekBase64;
|
||||
|
||||
@SerializedName("ciphertextBase64")
|
||||
private String ciphertextBase64;
|
||||
|
||||
@SerializedName("ivBase64")
|
||||
private String ivBase64;
|
||||
|
||||
public String getPhotoId() {
|
||||
return photoId;
|
||||
}
|
||||
@@ -18,4 +24,12 @@ public class EncryptedDek {
|
||||
public String getEncryptedDekBase64() {
|
||||
return encryptedDekBase64;
|
||||
}
|
||||
|
||||
public String getCiphertextBase64() {
|
||||
return ciphertextBase64;
|
||||
}
|
||||
|
||||
public String getIvBase64() {
|
||||
return ivBase64;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户照片元数据列表响应(供 Android / Web 展示用)。
|
||||
*
|
||||
* 对应后端 GET /api/user/photos/metadata 的 data 字段:
|
||||
* { "photos": [ { "photoId","uploadTime","deviceId","sn","activeDevice" }, ... ] }
|
||||
*
|
||||
* 仅含展示所需元数据,不含密文 / DEK / 明文。
|
||||
*/
|
||||
public class PhotoMetadataResponse {
|
||||
|
||||
@SerializedName("photos")
|
||||
private List<PhotoMeta> photos;
|
||||
|
||||
public List<PhotoMeta> getPhotos() { return photos; }
|
||||
public void setPhotos(List<PhotoMeta> photos) { this.photos = photos; }
|
||||
|
||||
/** 单条照片元数据 */
|
||||
public static class PhotoMeta {
|
||||
@SerializedName("photoId")
|
||||
private String photoId;
|
||||
|
||||
@SerializedName("uploadTime")
|
||||
private long uploadTime;
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private String deviceId;
|
||||
|
||||
@SerializedName("sn")
|
||||
private String sn;
|
||||
|
||||
@SerializedName("activeDevice")
|
||||
private boolean activeDevice;
|
||||
|
||||
public String getPhotoId() { return photoId; }
|
||||
public void setPhotoId(String photoId) { this.photoId = photoId; }
|
||||
|
||||
public long getUploadTime() { return uploadTime; }
|
||||
public void setUploadTime(long uploadTime) { this.uploadTime = uploadTime; }
|
||||
|
||||
public String getDeviceId() { return deviceId; }
|
||||
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
|
||||
|
||||
public String getSn() { return sn; }
|
||||
public void setSn(String sn) { this.sn = sn; }
|
||||
|
||||
public boolean isActiveDevice() { return activeDevice; }
|
||||
public void setActiveDevice(boolean activeDevice) { this.activeDevice = activeDevice; }
|
||||
}
|
||||
}
|
||||
@@ -3,43 +3,41 @@ package com.secure.demo.network.model;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 恢复设备请求(恢复出厂/换机后重新授权)。
|
||||
* { userId, sn, smsCode, newPublicKeyBase64 }
|
||||
* 恢复设备请求(恢复出厂/换机后重新授权,零信任:设备签名认证)。
|
||||
* { sn, challenge, signature, smsCode, newPublicKeyBase64 }
|
||||
*
|
||||
* - sn / challenge / signature:Challenge-Response 设备签名认证,服务端验签确认设备身份
|
||||
* - smsCode:短信验证码(恢复授权关键闸门)
|
||||
* - newPublicKeyBase64:设备新的 TEE 公钥,服务端用其包裹后续要下发的 DEK
|
||||
*/
|
||||
public class RecoverRequest {
|
||||
|
||||
@SerializedName("userId")
|
||||
private final String userId;
|
||||
|
||||
@SerializedName("sn")
|
||||
private final String sn;
|
||||
private String sn;
|
||||
|
||||
@SerializedName("challenge")
|
||||
private String challenge;
|
||||
|
||||
@SerializedName("signature")
|
||||
private String signature;
|
||||
|
||||
@SerializedName("smsCode")
|
||||
private final String smsCode;
|
||||
private String smsCode;
|
||||
|
||||
@SerializedName("newPublicKeyBase64")
|
||||
private final String newPublicKeyBase64;
|
||||
private String newPublicKeyBase64;
|
||||
|
||||
public RecoverRequest(String userId, String sn, String smsCode, String newPublicKeyBase64) {
|
||||
this.userId = userId;
|
||||
public RecoverRequest(String sn, String challenge, String signature, String smsCode, String newPublicKeyBase64) {
|
||||
this.sn = sn;
|
||||
this.challenge = challenge;
|
||||
this.signature = signature;
|
||||
this.smsCode = smsCode;
|
||||
this.newPublicKeyBase64 = newPublicKeyBase64;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getSmsCode() {
|
||||
return smsCode;
|
||||
}
|
||||
|
||||
public String getNewPublicKeyBase64() {
|
||||
return 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; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 设备注册请求:{ sn, publicKeyBase64 } */
|
||||
/**
|
||||
* 设备注册请求(PoP 版):{ sn, publicKeyBase64, challenge, signature }
|
||||
*
|
||||
* 修复 P0-1:注册必须携带 challenge + signature(TEE 私钥对 challenge 的签名),
|
||||
* 服务端用请求内公钥验签(Proof of Possession),证明私钥持有者确实拥有该公钥,
|
||||
* 防止攻击者用自己公钥冒名注册受害者 SN。
|
||||
*/
|
||||
public class RegisterRequest {
|
||||
|
||||
@SerializedName("sn")
|
||||
@@ -11,9 +17,17 @@ public class RegisterRequest {
|
||||
@SerializedName("publicKeyBase64")
|
||||
private final String publicKeyBase64;
|
||||
|
||||
public RegisterRequest(String sn, String publicKeyBase64) {
|
||||
@SerializedName("challenge")
|
||||
private final String challenge;
|
||||
|
||||
@SerializedName("signature")
|
||||
private final String signature;
|
||||
|
||||
public RegisterRequest(String sn, String publicKeyBase64, String challenge, String signature) {
|
||||
this.sn = sn;
|
||||
this.publicKeyBase64 = publicKeyBase64;
|
||||
this.challenge = challenge;
|
||||
this.signature = signature;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
@@ -23,4 +37,12 @@ public class RegisterRequest {
|
||||
public String getPublicKeyBase64() {
|
||||
return publicKeyBase64;
|
||||
}
|
||||
|
||||
public String getChallenge() {
|
||||
return challenge;
|
||||
}
|
||||
|
||||
public String getSignature() {
|
||||
return signature;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,36 @@ package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 发送短信验证码请求:{ phone } */
|
||||
/**
|
||||
* 发送短信验证码请求(零信任:设备签名认证)。
|
||||
* { sn, challenge, signature, phone }
|
||||
*
|
||||
* - sn / challenge / signature:Challenge-Response 设备签名认证,服务端验签确认设备身份后防轰炸
|
||||
* - phone:接收验证码的手机号
|
||||
*/
|
||||
public class SmsRequest {
|
||||
|
||||
@SerializedName("phone")
|
||||
private final String phone;
|
||||
@SerializedName("sn")
|
||||
private String sn;
|
||||
|
||||
public SmsRequest(String phone) {
|
||||
@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;
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
public String getSn() { return sn; }
|
||||
public String getChallenge() { return challenge; }
|
||||
public String getSignature() { return signature; }
|
||||
public String getPhone() { return phone; }
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ package com.secure.demo.network.model;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 上传加密照片请求(信封加密)。
|
||||
* { sn, photoId, ciphertextBase64, ivBase64, dekBase64, metadataSignature, metadata }
|
||||
* 服务端用用户密钥 UK 加密 DEK 后存储,永不保存明文 DEK。
|
||||
* 上传加密照片请求(信封加密 + 传输层 DEK 加密)。
|
||||
* { sn, photoId, ciphertextBase64, ivBase64, encryptedDekBase64, metadataSignature, metadata }
|
||||
*
|
||||
* 安全要点:
|
||||
* - encryptedDekBase64:DEK 用「服务端传输公钥」RSA-OAEP 加密后的密文(不再上传明文 DEK),
|
||||
* 服务端用其私有传输密钥解出 DEK 后,再用用户 UK 信封加密存储。
|
||||
* - 即使传输层被中间人截获,也无法还原 DEK、无法解密照片。
|
||||
*/
|
||||
public class UploadPhotoRequest {
|
||||
|
||||
@@ -21,8 +25,8 @@ public class UploadPhotoRequest {
|
||||
@SerializedName("ivBase64")
|
||||
private final String ivBase64;
|
||||
|
||||
@SerializedName("dekBase64")
|
||||
private final String dekBase64;
|
||||
@SerializedName("encryptedDekBase64")
|
||||
private final String encryptedDekBase64;
|
||||
|
||||
@SerializedName("metadataSignature")
|
||||
private final String metadataSignature;
|
||||
@@ -31,42 +35,22 @@ public class UploadPhotoRequest {
|
||||
private final String metadata;
|
||||
|
||||
public UploadPhotoRequest(String sn, String photoId, String ciphertextBase64,
|
||||
String ivBase64, String dekBase64,
|
||||
String ivBase64, String encryptedDekBase64,
|
||||
String metadataSignature, String metadata) {
|
||||
this.sn = sn;
|
||||
this.photoId = photoId;
|
||||
this.ciphertextBase64 = ciphertextBase64;
|
||||
this.ivBase64 = ivBase64;
|
||||
this.dekBase64 = dekBase64;
|
||||
this.encryptedDekBase64 = encryptedDekBase64;
|
||||
this.metadataSignature = metadataSignature;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getPhotoId() {
|
||||
return photoId;
|
||||
}
|
||||
|
||||
public String getCiphertextBase64() {
|
||||
return ciphertextBase64;
|
||||
}
|
||||
|
||||
public String getIvBase64() {
|
||||
return ivBase64;
|
||||
}
|
||||
|
||||
public String getDekBase64() {
|
||||
return dekBase64;
|
||||
}
|
||||
|
||||
public String getMetadataSignature() {
|
||||
return metadataSignature;
|
||||
}
|
||||
|
||||
public String getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
public String getSn() { return sn; }
|
||||
public String getPhotoId() { return photoId; }
|
||||
public String getCiphertextBase64() { return ciphertextBase64; }
|
||||
public String getIvBase64() { return ivBase64; }
|
||||
public String getEncryptedDekBase64() { return encryptedDekBase64; }
|
||||
public String getMetadataSignature() { return metadataSignature; }
|
||||
public String getMetadata() { return metadata; }
|
||||
}
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
package com.secure.device;
|
||||
|
||||
import android.content.Context;
|
||||
import android.provider.Settings;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.KeyStore;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PSSParameterSpec;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* 设备端安全模块(Android)
|
||||
*
|
||||
* 核心设计:
|
||||
* - TEE 密钥对(RSA-2048):私钥不可导出,用于解密服务端下发数据 + 签名
|
||||
* - TEE 密钥对(RSA-3072,见项3):私钥不可导出,用于解密服务端下发数据 + 签名
|
||||
* - 每次拍照生成随机 DEK(AES-256):用后即弃
|
||||
* - DEK 明文通过 HTTPS 传给服务端,服务端用 UK 加密后存储
|
||||
*
|
||||
@@ -38,8 +46,22 @@ public class DeviceCrypto {
|
||||
private static final String KEYSTORE_PROVIDER = "AndroidKeyStore";
|
||||
private static final String KEY_ALIAS = "device_tee_key";
|
||||
private static final String KEY_ALGO = KeyProperties.KEY_ALGORITHM_RSA;
|
||||
private static final int RSA_KEY_SIZE = 2048;
|
||||
private static final String SIGN_ALGO = "SHA256withRSA";
|
||||
|
||||
/**
|
||||
* 项3(密钥强度):RSA 密钥长度由 2048 提升到 3072。
|
||||
* NIST SP 800-57 建议 2030 年后停用 RSA-2048;3072 位提供约 128 位安全强度,
|
||||
* 适合作为长期设备凭证。若追求更高性能,可进一步改用 EC(secp256r1 / X25519)。
|
||||
*/
|
||||
private static final int RSA_KEY_SIZE = 3072;
|
||||
|
||||
/**
|
||||
* 项4(签名算法):由 PKCS#1 v1.5 签名(SHA256withRSA)升级为
|
||||
* RSA-PSS(SHA256withRSA/PSS)。PSS 概率性签名具备可证明的紧归约安全,
|
||||
* 比确定性的 PKCS#1 v1.5 更能抵抗选择密文攻击,是当前现代签名标准。
|
||||
* 注意:私钥的签名填充声明必须与服务端验签算法一致(见服务端 RsaUtil)。
|
||||
*/
|
||||
private static final String SIGN_ALGO = "SHA256withRSA/PSS";
|
||||
|
||||
private static final String RSA_TRANSFORM = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
|
||||
private static final int AES_KEY_SIZE = 256;
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
@@ -69,7 +91,17 @@ public class DeviceCrypto {
|
||||
keyStore.load(null);
|
||||
|
||||
if (!keyStore.containsAlias(KEY_ALIAS)) {
|
||||
// 首次:直接生成(会带 PSS 签名填充声明)
|
||||
generateTeeKeyPair();
|
||||
} else {
|
||||
// 迁移兼容:旧版本用 PKCS#1 v1.5 签名生成的密钥不支持 PSS,
|
||||
// 而当前 SIGN_ALGO 已升级为 SHA256withRSA/PSS。
|
||||
// 探测现有密钥是否支持 PSS 签名:不支持则删除旧密钥并重建,
|
||||
// 否则用旧密钥做 PSS 签名会抛 InvalidKeyException("Sign failed")。
|
||||
if (!isKeyPssCompatible()) {
|
||||
keyStore.deleteEntry(KEY_ALIAS);
|
||||
generateTeeKeyPair();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("KeyStore init failed", e);
|
||||
@@ -77,13 +109,33 @@ public class DeviceCrypto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 TEE / StrongBox 中生成 RSA-2048 密钥对
|
||||
* 探测已存在的 TEE 私钥是否支持当前签名算法(PSS)。
|
||||
* 通过尝试用 SIGN_ALGO 初始化 Signature(不真正签名)来判断,
|
||||
* 失败说明该密钥是在仅支持 PKCS#1 v1.5 的旧配置下生成的,需要重建。
|
||||
*/
|
||||
private boolean isKeyPssCompatible() {
|
||||
try {
|
||||
KeyStore.PrivateKeyEntry entry =
|
||||
(KeyStore.PrivateKeyEntry) keyStore.getEntry(KEY_ALIAS, null);
|
||||
Signature probe = Signature.getInstance(SIGN_ALGO);
|
||||
probe.initSign(entry.getPrivateKey());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 TEE / StrongBox 中生成 RSA-3072 密钥对(项3:密钥强度提升)
|
||||
*/
|
||||
private void generateTeeKeyPair() {
|
||||
try {
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance(
|
||||
KEY_ALGO, KEYSTORE_PROVIDER);
|
||||
|
||||
// 项4:私钥同时支持 OAEP 加密填充与 PSS 签名填充。
|
||||
// PSS(SHA256withRSA/PSS)为现代签名标准,需在密钥生成时显式声明,
|
||||
// 否则后续使用 PSS 签名会抛 InvalidKeyException。
|
||||
KeyGenParameterSpec strongBoxSpec = new KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_DECRYPT |
|
||||
@@ -92,7 +144,7 @@ public class DeviceCrypto {
|
||||
.setKeySize(RSA_KEY_SIZE)
|
||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
|
||||
.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
|
||||
.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PSS)
|
||||
.setUserAuthenticationRequired(false) // 无登录体系
|
||||
.setIsStrongBoxBacked(true) // 优先 StrongBox
|
||||
.build();
|
||||
@@ -113,7 +165,7 @@ public class DeviceCrypto {
|
||||
.setKeySize(RSA_KEY_SIZE)
|
||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
|
||||
.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
|
||||
.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PSS)
|
||||
.setUserAuthenticationRequired(false)
|
||||
.setIsStrongBoxBacked(false)
|
||||
.build();
|
||||
@@ -166,7 +218,7 @@ public class DeviceCrypto {
|
||||
*/
|
||||
public String getDeviceSN() {
|
||||
// return android.os.Build.getSerial();
|
||||
return "SN-DEMO-001";
|
||||
return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
|
||||
}
|
||||
|
||||
// ==================== 数据信封加密 ====================
|
||||
@@ -185,13 +237,16 @@ public class DeviceCrypto {
|
||||
public EncryptedPayload encryptData(byte[] plaintext) {
|
||||
try {
|
||||
// 1. 随机 DEK
|
||||
// 项5(密钥随机性强化):显式传入 SecureRandom,而非依赖平台默认源的
|
||||
// 不确定行为。与服务端 AesGcmUtil.generateKey() 保持一致的随机性来源,
|
||||
// 避免某些 Android 设备上 KeyGenerator 默认源熵不足导致 DEK 可预测的风险。
|
||||
KeyGenerator kg = KeyGenerator.getInstance("AES");
|
||||
kg.init(AES_KEY_SIZE);
|
||||
kg.init(AES_KEY_SIZE, new SecureRandom());
|
||||
SecretKey dek = kg.generateKey();
|
||||
|
||||
// 2. 随机 IV
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
java.security.SecureRandom random = new java.security.SecureRandom();
|
||||
SecureRandom random = new SecureRandom();
|
||||
random.nextBytes(iv);
|
||||
|
||||
// 3. AES-256-GCM 加密
|
||||
@@ -210,6 +265,41 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 元数据签名 ====================
|
||||
|
||||
/**
|
||||
@@ -217,6 +307,11 @@ public class DeviceCrypto {
|
||||
*
|
||||
* 签名内容示例:"SN-xxx|timestamp|photoId"
|
||||
* 服务端用设备公钥验签
|
||||
*
|
||||
* 跨平台一致性(项4):显式设置 PSS 参数,与服务端 RsaUtil.verifySignature 完全一致:
|
||||
* digest=SHA-256, MGF1+SHA-256, saltLength=32(=哈希输出长度), trailerField=1。
|
||||
* Android(Conscrypt)与 Java SE 对 "SHA256withRSA/PSS" 的默认 saltLength 不一致,
|
||||
* 若不显式声明,服务端验签会失败。
|
||||
*/
|
||||
public String signMetadata(String metadata) {
|
||||
try {
|
||||
@@ -225,8 +320,11 @@ public class DeviceCrypto {
|
||||
PrivateKey privateKey = entry.getPrivateKey();
|
||||
|
||||
Signature sig = Signature.getInstance(SIGN_ALGO);
|
||||
// 显式设置 PSS 参数,消除平台默认值差异(与服务端验签严格一致)
|
||||
sig.setParameter(
|
||||
new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
|
||||
sig.initSign(privateKey);
|
||||
sig.update(metadata.getBytes("UTF-8"));
|
||||
sig.update(metadata.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] signature = sig.sign();
|
||||
|
||||
return Base64.getEncoder().encodeToString(signature);
|
||||
@@ -281,21 +379,32 @@ public class DeviceCrypto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密服务端下发的加密 DEK
|
||||
* 恢复流程中,服务端用设备公钥加密每个照片的 DEK
|
||||
* 解密服务端下发的加密 DEK。
|
||||
*
|
||||
* @param encryptedDek 加密的 DEK(Base64)
|
||||
* @return 明文 DEK 字节
|
||||
* 与服务端 {@code /api/photo/recover} 的下发格式保持一致:服务端使用设备 RSA 公钥
|
||||
* 以 OAEP(SHA-256/MGF1)加密 DEK 的裸字节({@code dek.getEncoded()},即 32 字节 AES-256
|
||||
* 原始密钥),并以 Base64 下发单段密文。
|
||||
*
|
||||
* 设备侧:用 TEE 私钥 RSA-OAEP 直接解密该密文,得到 DEK 原始字节,
|
||||
* 再由调用方以 {@code new SecretKeySpec(decryptedDek, "AES")} 还原 SecretKey。
|
||||
*
|
||||
* 注意:历史版本曾采用"wrappedKey:cipher:iv"三段会话密钥格式,但服务端实际下发的是
|
||||
* 纯 RSA 单段密文,两侧格式不一致会导致解密失败。此处与服务器对齐为纯 RSA-OAEP 解密。
|
||||
*
|
||||
* @param encryptedDek 纯 RSA-OAEP 加密的单段 Base64 密文
|
||||
* @return 明文 DEK 字节(32 字节 AES-256 密钥原始字节)
|
||||
*/
|
||||
public byte[] decryptDek(String encryptedDekBase64) {
|
||||
public byte[] decryptDek(String encryptedDek) {
|
||||
try {
|
||||
// 纯 RSA-OAEP 单段密文:Base64 解码后用 TEE 私钥直接解密
|
||||
byte[] cipherBytes = Base64.getDecoder().decode(encryptedDek);
|
||||
|
||||
KeyStore.PrivateKeyEntry entry =
|
||||
(KeyStore.PrivateKeyEntry) keyStore.getEntry(KEY_ALIAS, null);
|
||||
PrivateKey privateKey = entry.getPrivateKey();
|
||||
|
||||
Cipher cipher = Cipher.getInstance(RSA_TRANSFORM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
return cipher.doFinal(Base64.getDecoder().decode(encryptedDekBase64));
|
||||
Cipher rsaCipher = Cipher.getInstance(RSA_TRANSFORM);
|
||||
rsaCipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
return rsaCipher.doFinal(cipherBytes);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Decrypt DEK failed", e);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,50 @@
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ④b 下载解密(流式 StreamingResponseBody) -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_download_streaming"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:onClick="@{click::downloadPhotoStreaming}"
|
||||
android:text="④b 下载解密(流式)"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ④c 列出全部照片 | ④d 设备本地解密全部照片(端到端加密) -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_list_photos"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:onClick="@{click::listAllPhotos}"
|
||||
android:text="④c 列出全部照片"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_download_all_local"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:onClick="@{click::downloadAllPhotosLocal}"
|
||||
android:text="④d 本地解密全部"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ⑤ 恢复出厂 | ⑥ 重新注册 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
Reference in New Issue
Block a user