diff --git a/README.md b/README.md index b910822..aa50674 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,10 @@ 设备端 后端 用户端 │ │ │ │── 生成 TEE 密钥对 ──────────│ │ - │── POST /api/device/register │ │ - │ {sn, publicKey} ──►│ │ + │── GET /api/device/challenge│ │ + │◄── {challenge} ────────────│ │ + │── POST /api/device/register│ │ + │ {sn,pubKey,challenge,sig}──►│── PoP 验签(challenge,sig) │ │ │── 存储 device(sn, pubKey) │ │◄── {deviceId} ─────────────│ │ │ │ │ @@ -100,8 +102,10 @@ 设备端(新) 后端 用户端 │ │ │ │── 新 TEE 密钥对 │ │ - │── POST /api/device/register │ │ - │ {sn, newPubKey} ──►│(旧设备自动停用) │ + │── GET /api/device/challenge │ │ + │◄── {challenge} ─────────────│ │ + │── POST /api/device/register │ │ + │ {sn,newPubKey,challenge,sig}►│(PoP 验签 + 旧设备自动停用) │ │◄── {newDeviceId} ───────────│ │ │ │ │ │ │◄── POST /api/device/sms/send │ @@ -336,14 +340,15 @@ TEE 密钥是「**门禁卡**」,UK / DEK 才是「**保险柜钥匙**」。 ### 数据持久化说明(JPA + MySQL) -设备状态、用户、照片索引已落库,**服务重启 / App 重启均不丢失注册与绑定状态**: +设备状态、用户、照片归属已落库,**服务重启 / App 重启均不丢失注册与绑定状态**: | 表名 | 对应实体 | 存储内容 | |---|---|---| | `device` | `Device` | 设备注册/绑定状态(SN 唯一、公钥、userId、active) | | `app_user` | `User` | 用户 + UK + 登录密码 | -| `encrypted_photo` | `EncryptedPhoto` | 照片元数据(密文仍落盘 `uploads/`) | -| `user_photo` | `UserPhoto` | 用户 ↔ 照片索引 | +| `encrypted_photo` | `EncryptedPhoto` | 照片元数据 + 归属用户(密文仍落盘 `uploads/`) | + +> 注:早期版本存在 `user_photo` 关联表(`UserPhoto` 实体),已废弃。照片归属统一由 `encrypted_photo.user_id` 承担,查询复用 `findByUserIdOrderByUploadTimeAsc`。 - 数据库脚本:`springboot-server/sql/schema.sql`(含建库建表 DDL)。 - 依赖 `spring.jpa.hibernate.ddl-auto=update` 会在启动时自动建表,也可手动执行 `schema.sql`。 @@ -370,11 +375,11 @@ TEE 密钥是「**门禁卡**」,UK / DEK 才是「**保险柜钥匙**」。 | Method | Path | 说明 | |---|---|---| | GET | `/api/device/status?sn=xxx` | 查询设备注册/绑定状态(App 启动复用,免重复注册绑定) | -| POST | `/api/device/register` | 设备注册(SN + 公钥,幂等:同 SN 同公钥返回原 deviceId) | +| POST | `/api/device/register` | 设备注册(SN + 公钥 + PoP 验签 `{challenge, signature}`,幂等:同 SN 同公钥返回原 deviceId) | | POST | `/api/photo/upload` | 上传加密照片(设备签名验签) | | POST | `/api/photo/recover` | 恢复后获取照片 DEK | -> **注册/绑定幂等说明**:`/api/device/register` 对「同一 SN + 相同公钥」幂等——App 重启后重复注册不会生成新 deviceId,也不会误停用自己;仅当公钥变化(恢复出厂)时才停旧换新。App 启动时建议先调 `GET /api/device/status?sn=xxx`,若 `registered=true` 且 `bound=true`,直接复用返回的 `deviceId`/`userId`,跳过注册与绑定。 +> **注册/绑定幂等说明**:`/api/device/register` 对「同一 SN + 相同公钥」幂等——App 重启后重复注册不会生成新 deviceId,也不会误停用自己;仅当公钥变化(恢复出厂)时才停旧换新。注册必须携带 PoP(先 `GET /api/device/challenge`,再用 TEE 私钥对 challenge 签名),服务端验签通过才允许注册,防止攻击者用自己公钥冒名注册受害者 SN(P0-1 修复)。App 启动时建议先调 `GET /api/device/status?sn=xxx`,若 `registered=true` 且 `bound=true`,直接复用返回的 `deviceId`/`userId`,跳过注册与绑定。 ### 用户端(需要登录 Token) diff --git a/android-app/app/src/main/java/com/secure/demo/activity/main/MainActivity.java b/android-app/app/src/main/java/com/secure/demo/activity/main/MainActivity.java index 733e20e..eb7ec4e 100644 --- a/android-app/app/src/main/java/com/secure/demo/activity/main/MainActivity.java +++ b/android-app/app/src/main/java/com/secure/demo/activity/main/MainActivity.java @@ -102,8 +102,12 @@ public class MainActivity extends BaseMvvmActivity 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 { + if (plaintextBytes != null && plaintextBytes.length > 0) { + displayDecryptedImage(plaintextBytes); + } + }); } /** @@ -131,10 +141,24 @@ public class MainActivity extends BaseMvvmActivity { 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 { - 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 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 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 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 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 Single deviceSignedAuthFlatMap( + io.reactivex.rxjava3.functions.Function> mapper) { + return ApiClient.deviceApi().deviceStatusChallenge() + .map(ApiResponse::getData) + .map(ChallengeResponse::getChallenge) + .map(challenge -> { + String signature = crypto.signMetadata(challenge); + return new DeviceStatusRequest(sn, challenge, signature); + }) + .flatMap(mapper); + } + + /** + * 设备本地解密单张照片:先取 challenge → TEE 签名 → 调后端下发密文+加密DEK+IV → 本地解密。 + * + * @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 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) { diff --git a/android-app/app/src/main/java/com/secure/demo/base/mvvm/UiState.java b/android-app/app/src/main/java/com/secure/demo/base/mvvm/UiState.java index 86594c1..065c3c5 100644 --- a/android-app/app/src/main/java/com/secure/demo/base/mvvm/UiState.java +++ b/android-app/app/src/main/java/com/secure/demo/base/mvvm/UiState.java @@ -59,6 +59,9 @@ public class UiState { /** 下载解密后的图片明文(Base64),供预览显示 */ private final MutableLiveData decryptedImageBase64 = new MutableLiveData<>(); + /** 下载解密后的图片明文(二进制字节),流式下载场景直接承载原始明文,无需 Base64 */ + private final MutableLiveData decryptedImageBytes = new MutableLiveData<>(); + /** 当前设备/用户状态摘要(SN、公钥、注册/绑定/恢复进度) */ private final MutableLiveData status = new MutableLiveData<>(""); @@ -109,6 +112,14 @@ public class UiState { decryptedImageBase64.postValue(base64); } + public LiveData getDecryptedImageBytes() { + return decryptedImageBytes; + } + + public void setDecryptedImageBytes(byte[] bytes) { + decryptedImageBytes.postValue(bytes); + } + public void setStatus(String status) { this.status.postValue(status); } diff --git a/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java b/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java index 72af977..ade3f4c 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java +++ b/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java @@ -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) { diff --git a/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java b/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java index 4866eff..62100be 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java +++ b/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java @@ -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> registerDevice(@Body RegisterRequest body); - /** 2. 用户绑定设备(用户端,Bearer Token / X-User-Id 鉴权) */ + /** 2. 绑定设备(零信任:设备签名认证,sn + challenge + signature + userId) */ @POST("api/device/bind") Single> bindDevice(@Body BindRequest body); - /** 3. 发送短信验证码(用户端,登录态防轰炸) */ + /** 3. 发送短信验证码(零信任:设备签名认证,防短信轰炸) */ @POST("api/device/sms/send") Single> sendSms(@Body SmsRequest body); @@ -60,7 +64,7 @@ public interface DeviceApi { @POST("api/photo/upload") Single> uploadPhoto(@Body UploadPhotoRequest body); - /** 5. 恢复设备(短信验证 + 新公钥,换机/恢复出厂后调用,用户端) */ + /** 5. 恢复设备(零信任:设备签名认证,短信验证 + 新公钥,换机/恢复出厂后调用) */ @POST("api/device/recover") Single> recoverDevice(@Body RecoverRequest body); @@ -68,14 +72,38 @@ public interface DeviceApi { @POST("api/photo/recover") Single> recoverPhotos(@Body PhotoRecoverRequest body); - /** 7. 用户下载并解密照片(Bearer Token / X-User-Id 鉴权,返回明文照片 Base64 的 JSON) */ - @GET("api/photo/{photoId}/decrypt") - Single> downloadAndDecrypt(@Path("photoId") String photoId, - @Header("X-User-Id") String userId); + /** + * 6b. 设备本地下载解密单张照片(端到端加密,设备端 Challenge-Response 签名认证)。 + * 服务端下发「密文 + 设备公钥加密的 DEK + IV」,设备在 TEE 内本地解密,服务端不接触明文。 + */ + @POST("api/device/photo/{photoId}/local") + Single> deviceDownloadLocal(@Path("photoId") String photoId, + @Body DeviceStatusRequest body); - /** 7b. 用户照片列表(用户端,X-User-Id 鉴权,返回 photoId 列表) */ - @GET("api/user/photos") - Single> userPhotos(@Header("X-User-Id") String userId); + /** 7. 用户下载并解密照片(Web 用户端,Bearer Token 鉴权,返回明文照片 Base64 的 JSON) */ + @GET("api/photo/{photoId}/decrypt") + Single> downloadAndDecrypt(@Path("photoId") String photoId); + + /** + * 7b. 用户下载并解密照片(Web 用户端,Bearer Token 鉴权,流式版)。 + * 后端以 {@code StreamingResponseBody} 直接写出原始二进制明文,不经过 JSON/Base64 包装, + * 边解密边传输,内存占用与照片大小无关。配合 {@code @Streaming} 让 Retrofit 不缓冲整段响应。 + */ + @Streaming + @GET("api/photo/{photoId}/decrypt/stream") + Single downloadAndDecryptStreaming(@Path("photoId") String photoId); + + /** 7b. 设备照片 ID 列表(零信任:设备签名认证,返回该设备绑定用户的照片列表) */ + @POST("api/device/photos") + Single> devicePhotos(@Body DeviceStatusRequest body); + + /** 8. 设备照片元数据列表(零信任:设备签名认证,含上传时间/来源设备/是否可设备端解密,供展示用) */ + @POST("api/device/photos/metadata") + Single> devicePhotosMetadata(@Body DeviceStatusRequest body); + + /** 9. 获取服务端传输公钥(设备端用它加密上传的 DEK,使 DEK 在网络上不明文) */ + @GET("api/device/transport-key") + Single>> transportPublicKey(); /** 8. 健康检查(公开) */ @GET("api/health") diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java index 144cb2e..325a296 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java +++ b/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java @@ -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; } } diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/DevicePhotoResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/DevicePhotoResponse.java new file mode 100644 index 0000000..70239ef --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/DevicePhotoResponse.java @@ -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; } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/EncryptedDek.java b/android-app/app/src/main/java/com/secure/demo/network/model/EncryptedDek.java index 7f12b04..040aa29 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/model/EncryptedDek.java +++ b/android-app/app/src/main/java/com/secure/demo/network/model/EncryptedDek.java @@ -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; + } } diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/PhotoMetadataResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/PhotoMetadataResponse.java new file mode 100644 index 0000000..66906c3 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/PhotoMetadataResponse.java @@ -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 photos; + + public List getPhotos() { return photos; } + public void setPhotos(List 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; } + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java index 1d37377..e9071c8 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java +++ b/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java @@ -3,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; } } diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/RegisterRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/RegisterRequest.java index dc4a559..3f73e23 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/model/RegisterRequest.java +++ b/android-app/app/src/main/java/com/secure/demo/network/model/RegisterRequest.java @@ -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; + } } diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java index 8c56982..8ca3d1f 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java +++ b/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java @@ -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; } } diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java index 574416e..ed6867a 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java +++ b/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java @@ -3,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; } } diff --git a/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java b/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java index 93e05c2..252a1a4 100644 --- a/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java +++ b/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java @@ -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); } diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 3e53fce..3f73dbf 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -141,6 +141,50 @@ android:textSize="13sp" /> + + + +