fix(device): 设备注册绑定接口添加 PoP 验签,照片下载改为设备端本地解密

This commit is contained in:
TongTongStudio
2026-08-24 21:32:22 +08:00
parent 417c4989f7
commit dbb75658a6
38 changed files with 2300 additions and 761 deletions

View File

@@ -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();

View File

@@ -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 + 公钥 上报后端
* 注册设备(设备端无登录):先取 challengeTEE 私钥签名后上报 SN + 公钥 + 签名
* 真实场景:用户购买设备后首次联网激活。
* 修复 P0-1注册携带 PoPchallenge + 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)。
* 设备本地下载解密最近一张照片(端到端加密,零知识服务端)。
*
* 不再依赖内存态 lastPhotoIdApp 重启会丢失),而是:
* ① 先调 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) {

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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")

View File

@@ -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 / signatureChallenge-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; }
}

View File

@@ -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 }
*
* - encryptedDekBase64DEK 被设备公钥加密RSA-OAEP仅设备私钥可解
* - ciphertextBase64照片密文AES-256-GCM
* - ivBase64GCM 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; }
}

View File

@@ -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;
}
}

View File

@@ -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; }
}
}

View File

@@ -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 / signatureChallenge-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; }
}

View File

@@ -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 + signatureTEE 私钥对 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;
}
}

View File

@@ -2,17 +2,36 @@ package com.secure.demo.network.model;
import com.google.gson.annotations.SerializedName;
/** 发送短信验证码请求:{ phone } */
/**
* 发送短信验证码请求(零信任:设备签名认证)。
* { sn, challenge, signature, phone }
*
* - sn / challenge / signatureChallenge-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; }
}

View File

@@ -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 }
*
* 安全要点:
* - encryptedDekBase64DEK 用「服务端传输公钥」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; }
}

View File

@@ -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):私钥不可导出,用于解密服务端下发数据 + 签名
* - 每次拍照生成随机 DEKAES-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-20483072 位提供约 128 位安全强度,
* 适合作为长期设备凭证。若追求更高性能,可进一步改用 ECsecp256r1 / X25519
*/
private static final int RSA_KEY_SIZE = 3072;
/**
* 项4签名算法由 PKCS#1 v1.5 签名SHA256withRSA升级为
* RSA-PSSSHA256withRSA/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 签名填充。
// PSSSHA256withRSA/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 明文 DEKAES-256 原始字节32 字节)
* @param serverPublicKeyB64 服务端传输公钥Base64来自 GET /api/device/transport-key
* @return 用服务端公钥加密后的 DEK 密文Base64 单段)
*/
public String encryptDekWithTransportKey(byte[] dekBytes, String serverPublicKeyB64) {
try {
byte[] pubEncoded = Base64.getDecoder().decode(serverPublicKeyB64);
KeyFactory kf = KeyFactory.getInstance("RSA");
PublicKey serverPub = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(pubEncoded));
Cipher cipher = Cipher.getInstance(RSA_TRANSFORM);
// 显式指定 OAEP 参数(消息摘要 SHA-256 + MGF1-SHA256与服务端严格一致
// 消除 Android(Conscrypt) 与 OpenJDK 对 OAEP/MGF1 默认哈希解释不一致导致的解密失败。
javax.crypto.spec.OAEPParameterSpec oaepSpec =
new javax.crypto.spec.OAEPParameterSpec(
"SHA-256", "MGF1",
java.security.spec.MGF1ParameterSpec.SHA256,
javax.crypto.spec.PSource.PSpecified.DEFAULT);
cipher.init(Cipher.ENCRYPT_MODE, serverPub, oaepSpec);
byte[] encrypted = cipher.doFinal(dekBytes);
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new RuntimeException("Encrypt DEK with transport key failed: " + e.getMessage(), e);
}
}
// ==================== 元数据签名 ====================
/**
@@ -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。
* AndroidConscrypt与 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 加密的 DEKBase64
* @return 明文 DEK 字节
* 与服务端 {@code /api/photo/recover} 的下发格式保持一致:服务端使用设备 RSA 公钥
* 以 OAEPSHA-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);
}

View File

@@ -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"