feat(e2e): 端对端加密完全对齐老项目 — cipher_guard_sdk 修正 + EncryptionManager 集成
Some checks failed
CI / Lint (push) Has been cancelled
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:
335
apps/im_app/lib/core/services/encryption_manager.dart
Normal file
335
apps/im_app/lib/core/services/encryption_manager.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user