feat(e2e): 端对端加密完全对齐老项目 — cipher_guard_sdk 修正 + EncryptionManager 集成
Some checks failed
CI / Lint (push) Has been cancelled

修正 cipher_guard_sdk 4 个关键密码学差异使其与老 Flutter 项目 (im-client-im-dev) 和 iOS
EncryptionManager 完全互操作:

1. AES: 显式 SIC/CTR 模式 + 16 zero-byte IV(原 SDK 用随机 IV + KDF 派生密钥)
2. RSA: bare RSAEngine 无 PKCS1 padding(原 SDK 用 PKCS1Encoding)
3. Session key: 32-char alphanumeric ASCII(原 SDK 用 base64 random bytes)
4. Wire format: base64(ciphertext) 无 IV 前缀

新增 EncryptionManager:
- Per-chat round-based key chain(最多 10 rounds/chat,FIFO 淘汰)
- 登录后自动 setup:RSA 密钥对生成/存储 + 公钥上传 + chat 密钥拉取解密
- API 集成:cipher/v2/key/my, key/set, chat/my
- 消息加密返回 JSON envelope {"round":N,"data":"<base64>"}
- 消息解密兼容 JSON envelope + legacy raw base64

集成到消息流:
- SendMessageUseCase: 发送前加密 content → wireContent
- WsMessageService: 收到消息后解密 content + lastMsg
- 无密钥时 fallback 到明文(对齐 iOS 行为)

注意:/app/api/cipher/v2/key/set 仍为预发布接口,仅测试阶段使用

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-04-14 21:44:00 +09:00
parent e8f58212e6
commit 52a3f0f45c
18 changed files with 1307 additions and 367 deletions

View File

@@ -1,8 +1,10 @@
import 'package:cipher_guard_sdk/cipher_guard_sdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/core/foundation/device_info.dart';
import 'package:im_app/core/services/app_initializer.dart';
import 'package:im_app/core/services/encryption_manager.dart';
import 'package:im_app/app/di/network_provider.dart';
// ── 认证 ──────────────────────────────────────────────────────────────────────
@@ -26,32 +28,60 @@ class AuthNotifier extends ChangeNotifier {
/// 登录用户的 UID登录成功后由 LoginViewModel 写入
int? get currentUid => _currentUid;
/// E2E EncryptionManager — 由外部注入(见 LoginViewModel
EncryptionManager? _encryptionManager;
NetworksSdkApi? _api;
void setEncryptionDeps(EncryptionManager encMgr, NetworksSdkApi api) {
_encryptionManager = encMgr;
_api = api;
}
void login({required int uid}) {
_isLoggedIn = true;
_currentUid = uid;
// TODO: 接入 cipher_guard_sdk 后,在此处完成 RSA 密钥注入:
// 1. 从安全存储keychain / secure storage读取公私钥对只读一次
// 2. cipherSdk.setActiveKeyPair(publicKey: pubPem, privateKey: privPem)
// 须在 notifyListeners() 之前完成,确保路由跳转后 onEncryptRequest 回调触发时密钥已就绪。
notifyListeners();
// E2E setup: 对齐 iOS AppCoordinator.onLogin → EncryptionManager.setup()
if (_encryptionManager != null && _api != null) {
_encryptionManager!.setup(_api!);
}
}
void logout() {
_isLoggedIn = false;
_currentUid = null;
// TODO: 接入 cipher_guard_sdk 后,退出登录时清除内存密钥
// cipherSdk.clearActiveKeyPair()
// cipherSdk.clearDerivedKeyCache()
// E2E teardown: 清除所有加密密钥
_encryptionManager?.clearKeys();
notifyListeners();
}
}
/// 登录状态 Provider
///
/// 使用 [Provider] 持有 [AuthNotifier] 单例
/// go_router 通过 [GoRouter.refreshListenable] 直接监听 [AuthNotifier]ChangeNotifier
/// Riverpod 侧不需要响应式更新(导航由 go_router 接管)。
final authNotifierProvider = Provider<AuthNotifier>((ref) => AuthNotifier());
/// 自动注入 EncryptionManager + API clientlogin() 后自动触发 E2E setup
final authNotifierProvider = Provider<AuthNotifier>((ref) {
final auth = AuthNotifier();
auth.setEncryptionDeps(
ref.read(encryptionManagerProvider),
ref.read(networkSdkApiProvider),
);
return auth;
});
// ── E2E 加密 ────────────────────────────────────────────────────────────────
/// CipherGuardSdkApi 单例 — 对齐老项目加密引擎
final cipherSdkProvider = Provider<CipherGuardSdkApi>((ref) {
return CipherGuardSdkApi();
});
/// EncryptionManager 单例 — per-chat key chain + API integration
///
/// 登录后调用 `encMgr.setup(api)` 启动 E2E退出时调用 `encMgr.clearKeys()`。
/// 对齐 iOS EncryptionManager + 老项目 EncryptionMgr。
final encryptionManagerProvider = Provider<EncryptionManager>((ref) {
return EncryptionManager(cipherSdk: ref.read(cipherSdkProvider));
});
// ── 主题 ──────────────────────────────────────────────────────────────────────

View File

@@ -56,6 +56,18 @@ class ApiPaths {
static const favoriteFetchByIds = '/app/api/favorite/favorite';
static const favoriteTags = '/app/api/favorite/tags';
// ── Cipher (E2E Encryption) ──
// 注意:/app/api/cipher/v2/key/set 是预发布接口,仅测试阶段使用
static const cipherKeyMy = '/app/api/cipher/v2/key/my';
static const cipherKeySet = '/app/api/cipher/v2/key/set';
static const cipherKeyGets = '/app/api/cipher/v2/key/gets';
static const cipherChatGet = '/app/api/cipher/v2/chat/get';
static const cipherChatMy = '/app/api/cipher/v2/chat/my';
static const cipherChatUpdate = '/app/api/cipher/v2/chat/update';
static const cipherChatRequest = '/app/api/cipher/v2/chat/request';
static const cipherChatSessionsExist =
'/app/api/cipher/v2/chat/sessions_exist';
// ── WebSocket ──
static const wsConnect = '/websock/open';
}

View File

@@ -0,0 +1,335 @@
import 'dart:convert';
import 'package:cipher_guard_sdk/cipher_guard_sdk.dart';
import 'package:flutter/foundation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:im_app/data/remote/cipher_api_requests.dart';
/// Per-chat AES key manager with round-based key chain — 对齐 iOS EncryptionManager
///
/// 协议(对齐老项目 im-client-im-dev EncryptionMgr + iOS EncryptionManager
/// 1. RSA-1024 key pair: 生成一次,私钥存 secure storage
/// 2. 公钥上传到服务器: POST /app/api/cipher/v2/key/set
/// 3. 所有聊天 cipher session 获取: GET /app/api/cipher/v2/chat/my
/// 服务器返回 [{ chat_id, session: Base64(RSA_raw_encrypt(32-char AES key)), round }]
/// 4. 每个 session 用 RSA 私钥解密 → 32-char AES key per chat
/// 5. 消息加解密:
/// Wire format (new): JSON {"round": N, "data": "<Base64 AES-SIC ciphertext>"}
/// Legacy format: raw Base64 ciphertext (无 round — 使用该 chat 最新 key)
/// AES-256 SIC/CTR mode, key = UTF-8(32-char key), IV = 16 zero bytes
///
/// Key chain:
/// keyChain[chatId][round] = aesKey (32-char string)
/// 最多 maxKeyChainDepth(10) rounds per chat; 最旧的 round 先被淘汰
/// Key chain 持久化到 secure storage 作为 JSON
class EncryptionManager {
EncryptionManager({
required CipherGuardSdkApi cipherSdk,
}) : _cipherSdk = cipherSdk;
final CipherGuardSdkApi _cipherSdk;
// chatId → (round → 32-char AES key)
final Map<int, Map<int, String>> _keyChain = {};
bool _isSetup = false;
bool _isSettingUp = false;
static const int maxKeyChainDepth = 10;
// 对齐老项目 EncryptionMgr使用 localStorageMgr (SharedPreferences) 存储密钥。
// TODO(security): 迁移到 flutter_secure_storage 使用 Keychain/Keystore。
static const String _keyChainStorageKey = 'enc.keychain.json';
static const String _rsaPublicKeyStorageKey = 'enc.rsa.public';
static const String _rsaPrivateKeyStorageKey = 'enc.rsa.private';
bool get isSetup => _isSetup;
// ── Teardown ──────────────────────────────────────────────────────────────
Future<void> clearKeys() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_rsaPublicKeyStorageKey);
await prefs.remove(_rsaPrivateKeyStorageKey);
await prefs.remove(_keyChainStorageKey);
_keyChain.clear();
_isSetup = false;
_cipherSdk.clearActiveKeyPair();
debugPrint('[EncMgr] all keys cleared');
}
// ── Setup ─────────────────────────────────────────────────────────────────
/// Call once after login. Idempotent.
Future<void> setup(NetworksSdkApi api) async {
if (_isSetup || _isSettingUp) return;
_isSettingUp = true;
try {
// 1. Load or generate RSA key pair
final prefs = await SharedPreferences.getInstance();
var publicPem = prefs.getString(_rsaPublicKeyStorageKey);
var privatePem = prefs.getString(_rsaPrivateKeyStorageKey);
if (publicPem == null || privatePem == null) {
debugPrint('[EncMgr] generating RSA-1024 key pair...');
final keyPair = await _cipherSdk.generateRsaKeyPair(keySize: 1024);
publicPem = keyPair.publicKey;
privatePem = keyPair.privateKey;
await prefs.setString(_rsaPublicKeyStorageKey, publicPem);
await prefs.setString(_rsaPrivateKeyStorageKey, privatePem);
debugPrint('[EncMgr] RSA key pair stored');
}
// Inject into cipher SDK for session key encrypt/decrypt
_cipherSdk.setActiveKeyPair(
publicKey: publicPem, privateKey: privatePem);
// 2. Upload public key if server doesn't have one
await _uploadPublicKeyIfNeeded(api, publicPem, privatePem);
// 3. Load existing key chain from secure storage
await _loadKeyChainFromStorage();
// 4. Fetch and decrypt all chat keys from server
final success = await _fetchAndDecryptAllChatKeys(api);
if (success) {
_isSetup = true;
debugPrint('[EncMgr] setup complete, ${_keyChain.length} chats loaded');
}
} catch (e) {
debugPrint('[EncMgr] setup failed: $e');
} finally {
_isSettingUp = false;
}
}
// ── Round / key chain API ─────────────────────────────────────────────────
/// Current active round for a chat (highest round number), or null.
int? currentRound(int chatId) {
final rounds = _keyChain[chatId];
if (rounds == null || rounds.isEmpty) return null;
return rounds.keys.reduce((a, b) => a > b ? a : b);
}
/// Retrieve a specific round's AES key.
String? chatKey(int chatId, {int? round}) {
final rounds = _keyChain[chatId];
if (rounds == null || rounds.isEmpty) return null;
if (round != null) return rounds[round];
// No round specified — return latest
final latest = rounds.keys.reduce((a, b) => a > b ? a : b);
return rounds[latest];
}
// ── Encrypt / Decrypt API ─────────────────────────────────────────────────
/// AES-SIC encrypt plaintext for a chat.
/// Returns JSON `{"round":N,"data":"<base64>"}`, or null if no key.
///
/// 对齐 iOS EncryptionManager.encryptContent()
Future<String?> encryptContent(String plaintext, {required int chatId}) async {
final round = currentRound(chatId);
if (round == null) return null;
final key = _keyChain[chatId]?[round];
if (key == null) return null;
try {
final result = await _cipherSdk.encryptMessage(
plaintext: plaintext,
sessionKey: key,
round: round,
);
// Build JSON envelope matching iOS format
return jsonEncode({'round': round, 'data': result.data});
} catch (e) {
debugPrint('[EncMgr] encrypt failed for chatId=$chatId: $e');
return null;
}
}
/// Try to AES-SIC decrypt a message's content field.
///
/// Accepts two formats:
/// - New: JSON `{"round": N, "data": "<base64>"}` — uses round-specific key
/// - Legacy: raw Base64 ciphertext — uses latest key for the chat
///
/// Returns null if no suitable key or decryption fails.
Future<String?> decryptContent(String content, {required int chatId}) async {
// Try new JSON envelope format first
try {
final obj = jsonDecode(content);
if (obj is Map<String, dynamic> &&
obj.containsKey('round') &&
obj.containsKey('data')) {
final round = obj['round'] as int;
final b64Data = obj['data'] as String;
final key = _keyChain[chatId]?[round];
if (key == null) {
debugPrint(
'[EncMgr] decrypt: round=$round key missing for chatId=$chatId');
return null; // caller should mark ref_typ=4
}
return await _cipherSdk.decryptMessage(
encryptedData: b64Data,
sessionKey: key,
round: round,
);
}
} catch (_) {
// Not JSON — try legacy format
}
// Legacy: raw Base64 — use latest key
final key = chatKey(chatId);
if (key == null) return null;
final round = currentRound(chatId) ?? 0;
try {
return await _cipherSdk.decryptMessage(
encryptedData: content,
sessionKey: key,
round: round,
);
} catch (e) {
debugPrint('[EncMgr] legacy decrypt failed for chatId=$chatId: $e');
return null;
}
}
// ── Private: server API ───────────────────────────────────────────────────
Future<void> _uploadPublicKeyIfNeeded(
NetworksSdkApi api,
String publicPem,
String privatePem,
) async {
try {
// Check if server already has a key
final resp = await api.executeRequest(CipherGetMyKeyRequest());
if (resp != null && resp.publicKey.isNotEmpty) {
debugPrint('[EncMgr] server already has public key, skipping upload');
return;
}
} catch (e) {
debugPrint('[EncMgr] key/my check failed: $e');
// Continue — attempt upload anyway
}
// enc_pk: encrypted private key backup (对齐 iOS — uses raw PEM as placeholder)
final encPk = await _cipherSdk.encryptPrivateKey(
privateKey: privatePem,
password: 'default', // placeholder, same as old project demo
);
try {
await api.executeRequest(
CipherSetKeyRequest(publicKey: publicPem, encPk: encPk),
);
debugPrint('[EncMgr] public key uploaded OK');
} catch (e) {
debugPrint('[EncMgr] key/set upload failed: $e');
}
}
Future<bool> _fetchAndDecryptAllChatKeys(NetworksSdkApi api) async {
try {
final chatKeys = await api.executeRequest(CipherGetMyChatKeysRequest());
if (chatKeys == null || chatKeys.isEmpty) {
debugPrint('[EncMgr] no chat keys from server');
return true; // Empty is OK
}
for (final ck in chatKeys) {
if (ck.session == null || ck.session!.isEmpty) continue;
try {
// RSA raw decrypt the session → 32-char AES key
final decryptedKey =
await _cipherSdk.decryptSessionKeyWithActiveKey(
encryptedSessionKey: ck.session!,
);
// Strip leading null bytes (RSA raw output may have leading zeros)
final cleanKey = _stripLeadingZeros(decryptedKey);
if (cleanKey.length == 32) {
_storeKey(cleanKey, ck.chatId ?? 0, ck.round ?? 0);
} else {
debugPrint(
'[EncMgr] key length ${cleanKey.length} != 32 for chatId=${ck.chatId}');
}
} catch (e) {
debugPrint(
'[EncMgr] RSA decrypt failed for chatId=${ck.chatId}: $e');
}
}
// Persist key chain
await _saveKeyChainToStorage();
return true;
} catch (e) {
debugPrint('[EncMgr] fetchAndDecryptAllChatKeys failed: $e');
return false;
}
}
/// Strip leading zero chars from RSA raw decrypt output
String _stripLeadingZeros(String input) {
var i = 0;
while (i < input.length && input.codeUnitAt(i) == 0) {
i++;
}
return i > 0 ? input.substring(i) : input;
}
void _storeKey(String key, int chatId, int round) {
_keyChain.putIfAbsent(chatId, () => {});
_keyChain[chatId]![round] = key;
// Evict oldest rounds if over limit
final rounds = _keyChain[chatId]!;
while (rounds.length > maxKeyChainDepth) {
final oldest = rounds.keys.reduce((a, b) => a < b ? a : b);
rounds.remove(oldest);
}
}
// ── Key chain persistence ─────────────────────────────────────────────────
Future<void> _loadKeyChainFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_keyChainStorageKey);
if (json == null || json.isEmpty) return;
final decoded = jsonDecode(json) as Map<String, dynamic>;
for (final entry in decoded.entries) {
final chatId = int.tryParse(entry.key);
if (chatId == null) continue;
final rounds = entry.value as Map<String, dynamic>;
_keyChain[chatId] = {};
for (final re in rounds.entries) {
final round = int.tryParse(re.key);
if (round == null) continue;
_keyChain[chatId]![round] = re.value as String;
}
}
} catch (e) {
debugPrint('[EncMgr] loadKeyChain failed: $e');
}
}
Future<void> _saveKeyChainToStorage() async {
try {
final serializable = <String, Map<String, String>>{};
for (final entry in _keyChain.entries) {
serializable[entry.key.toString()] = entry.value.map(
(k, v) => MapEntry(k.toString(), v),
);
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyChainStorageKey, jsonEncode(serializable));
} catch (e) {
debugPrint('[EncMgr] saveKeyChain failed: $e');
}
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/core/services/encryption_manager.dart';
import 'package:im_app/core/services/socket_manager.dart';
import 'package:im_app/core/services/typing_indicator_manager.dart';
import 'package:im_app/data/remote/fetch_history_request.dart';
@@ -33,6 +34,7 @@ class WsMessageService {
final MessageRepository _messageRepo;
final ChatRepository _chatRepo;
final TypingIndicatorManager _typingManager;
final EncryptionManager? _encryptionManager;
StreamSubscription<Map<String, dynamic>>? _sub;
@@ -42,11 +44,13 @@ class WsMessageService {
required MessageRepository messageRepo,
required ChatRepository chatRepo,
required TypingIndicatorManager typingManager,
EncryptionManager? encryptionManager,
}) : _socketManager = socketManager,
_apiClient = apiClient,
_messageRepo = messageRepo,
_chatRepo = chatRepo,
_typingManager = typingManager;
_typingManager = typingManager,
_encryptionManager = encryptionManager;
// ── 生命周期 ──────────────────────────────────────────────────────────────
@@ -162,7 +166,21 @@ class WsMessageService {
);
if (response == null || response.messages.isEmpty) return;
final entities = response.messages.map((m) => m.toEntity()).toList();
// E2E 解密:对齐 iOS MessageReceiver — 收到消息后解密 content
var entities = response.messages.map((m) => m.toEntity()).toList();
final encMgr = _encryptionManager;
if (encMgr != null && encMgr.isSetup) {
entities = await Future.wait(entities.map((msg) async {
if (msg.content == null || msg.content!.isEmpty) return msg;
final decrypted = await encMgr.decryptContent(
msg.content!,
chatId: chatId,
);
// decrypted == null → 无法解密或无 key → 保留原文(对齐 iOS fallback
return decrypted != null ? msg.copyWith(content: decrypted) : msg;
}));
}
await _messageRepo.insertOrReplaceAll(entities);
debugPrint(
'[WsMessageService] saved ${entities.length} messages for chat $chatId',
@@ -184,10 +202,23 @@ class WsMessageService {
final existing = await _chatRepo.getChat(chatId);
if (existing == null) return;
final lastMsg = entry['last_msg'] as String?;
var lastMsg = entry['last_msg'] as String?;
final lastTyp = (entry['typ'] as num?)?.toInt();
final msgIdx = (entry['msg_idx'] as num?)?.toInt();
// E2E 解密 lastMsg对齐 iOS ConversationSnippetCache 解密逻辑)
final encMgr = _encryptionManager;
if (lastMsg != null && lastMsg.isNotEmpty &&
encMgr != null && encMgr.isSetup) {
final decrypted = await encMgr.decryptContent(lastMsg, chatId: chatId);
if (decrypted != null) {
lastMsg = decrypted;
} else if (lastMsg.startsWith('{')) {
// 无法解密且看起来是 JSON 密文 → 不在 UI 显示密文
lastMsg = '[Encrypted message]';
}
}
// 防止乱序帧覆盖新数据Codex review #4
if (msgIdx != null && existing.msgIdx != null && msgIdx < existing.msgIdx!) {
return;

View File

@@ -0,0 +1,170 @@
import 'package:networks_sdk/networks_sdk.dart';
/// 对齐老项目 /app/api/cipher/v2/* API 端点
///
/// /app/api/cipher/v2/key/set 是预发布接口,仅测试阶段使用。
// ── GET /app/api/cipher/v2/key/my — 获取自己的公钥 ────────────────────────
class CipherMyKeyResponse {
final String publicKey;
final String? encPrivate;
final int? uid;
const CipherMyKeyResponse({
required this.publicKey,
this.encPrivate,
this.uid,
});
factory CipherMyKeyResponse.fromJson(Map<String, dynamic> json) =>
CipherMyKeyResponse(
publicKey: (json['public_key'] ?? '') as String,
encPrivate: json['enc_pk'] as String?,
uid: json['uid'] as int?,
);
}
class CipherGetMyKeyRequest extends ApiRequestable<CipherMyKeyResponse?> {
@override
String get path => '/app/api/cipher/v2/key/my';
@override
HttpMethod get method => HttpMethod.get;
@override
Map<String, dynamic> get parameters => {};
@override
CipherMyKeyResponse? decodeResponse(dynamic response) {
final data = (response as dynamic).data;
if (data is! Map<String, dynamic>) return null;
return CipherMyKeyResponse.fromJson(data);
}
}
// ── POST /app/api/cipher/v2/key/set — 上传公钥 ─────────────────────────────
class CipherSetKeyRequest extends ApiRequestable<void> {
final String publicKey;
final String encPk;
CipherSetKeyRequest({required this.publicKey, required this.encPk});
@override
String get path => '/app/api/cipher/v2/key/set';
@override
HttpMethod get method => HttpMethod.post;
@override
Map<String, dynamic> get parameters => {
'public_key': publicKey,
'enc_pk': encPk,
};
@override
void decodeResponse(dynamic response) {}
}
// ── GET /app/api/cipher/v2/chat/my — 获取所有聊天的加密密钥 ─────────────────
class CipherChatKeyItem {
final int? chatId;
final String? session;
final int? round;
const CipherChatKeyItem({this.chatId, this.session, this.round});
factory CipherChatKeyItem.fromJson(Map<String, dynamic> json) =>
CipherChatKeyItem(
chatId: json['chat_id'] as int?,
session: json['session'] as String?,
round: json['round'] as int?,
);
}
class CipherGetMyChatKeysRequest
extends ApiRequestable<List<CipherChatKeyItem>?> {
@override
String get path => '/app/api/cipher/v2/chat/my';
@override
HttpMethod get method => HttpMethod.get;
@override
Map<String, dynamic> get parameters => {};
@override
List<CipherChatKeyItem>? decodeResponse(dynamic response) {
final data = (response as dynamic).data;
if (data is! List) return null;
return data
.cast<Map<String, dynamic>>()
.map(CipherChatKeyItem.fromJson)
.toList();
}
}
// ── GET /app/api/cipher/v2/key/gets — 获取其他用户的公钥 ─────────────────────
class CipherUserKeyResponse {
final int? uid;
final String? publicKey;
const CipherUserKeyResponse({this.uid, this.publicKey});
factory CipherUserKeyResponse.fromJson(Map<String, dynamic> json) =>
CipherUserKeyResponse(
uid: json['uid'] as int?,
publicKey: json['public_key'] as String?,
);
}
class CipherGetUsersKeysRequest
extends ApiRequestable<List<CipherUserKeyResponse>?> {
final List<int> userIds;
CipherGetUsersKeysRequest({required this.userIds});
@override
String get path => '/app/api/cipher/v2/key/gets';
@override
HttpMethod get method => HttpMethod.get;
@override
Map<String, dynamic> get parameters => {
'uids': userIds.join(','),
};
@override
List<CipherUserKeyResponse>? decodeResponse(dynamic response) {
final data = (response as dynamic).data;
if (data is! List) return null;
return data
.cast<Map<String, dynamic>>()
.map(CipherUserKeyResponse.fromJson)
.toList();
}
}
// ── POST /app/api/cipher/v2/chat/update — 更新聊天加密密钥 ──────────────────
class CipherUpdateChatKeysRequest extends ApiRequestable<void> {
final List<Map<String, dynamic>> sessions;
CipherUpdateChatKeysRequest({required this.sessions});
@override
String get path => '/app/api/cipher/v2/chat/update';
@override
HttpMethod get method => HttpMethod.post;
@override
Map<String, dynamic> get parameters => {'sessions': sessions};
@override
void decodeResponse(dynamic response) {}
}

View File

@@ -69,6 +69,7 @@ final wsMessageServiceProvider = Provider<WsMessageService>((ref) {
messageRepo: ref.read(messageRepositoryProvider),
chatRepo: ref.read(chatRepositoryProvider),
typingManager: ref.read(typingIndicatorManagerProvider),
encryptionManager: ref.read(encryptionManagerProvider),
);
service.start();
@@ -91,6 +92,7 @@ final sendMessageUseCaseProvider = Provider<SendMessageUseCase>((ref) {
messageRepo: ref.read(messageRepositoryProvider),
chatRepo: ref.read(chatRepositoryProvider),
currentUid: uid,
encryptionManager: ref.read(encryptionManagerProvider),
);
});

View File

@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/core/services/encryption_manager.dart';
import 'package:im_app/data/remote/send_message_request.dart';
import 'package:im_app/domain/entities/message.dart';
import 'package:im_app/domain/repositories/chat_repository.dart';
@@ -25,6 +26,7 @@ class SendMessageUseCase {
final NetworksSdkApi _apiClient;
final MessageRepository _messageRepo;
final ChatRepository _chatRepo;
final EncryptionManager? _encryptionManager;
final int currentUid;
SendMessageUseCase({
@@ -32,9 +34,11 @@ class SendMessageUseCase {
required MessageRepository messageRepo,
required ChatRepository chatRepo,
required this.currentUid,
EncryptionManager? encryptionManager,
}) : _apiClient = apiClient,
_messageRepo = messageRepo,
_chatRepo = chatRepo;
_chatRepo = chatRepo,
_encryptionManager = encryptionManager;
Future<void> execute({
required int chatId,
@@ -56,13 +60,25 @@ class SendMessageUseCase {
),
);
// 2. HTTP 发送
// 2. E2E 加密(对齐 iOS MessageHistoryService.sendMessage
// wireContent = EncryptionManager.encryptContent(content, chatId) ?? content
String wireContent = content;
if (_encryptionManager != null && _encryptionManager.isSetup) {
final encrypted =
await _encryptionManager.encryptContent(content, chatId: chatId);
if (encrypted != null) {
wireContent = encrypted;
}
// null = no key for chat → send plaintext (对齐老项目 fallback)
}
// 3. HTTP 发送
SendMessageResponse? resp;
try {
resp = await _apiClient.executeRequest(
SendMessageRequest(
chatId: chatId,
content: content,
content: wireContent,
typ: typ,
sendTime: sendTime,
),

View File

@@ -116,6 +116,9 @@ dependencies:
cached_network_image: ^3.3.1
crypto: ^3.0.6
# 本地键值存储(加密 key chain 持久化)
shared_preferences: ^2.5.3
# 图片保存到相册(#32
image_gallery_saver_plus: ^3.0.5