Compare commits
2 Commits
bundle
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a3f0f45c | ||
|
|
e8f58212e6 |
67
AGENTS.md
67
AGENTS.md
@@ -1,67 +0,0 @@
|
||||
<!-- agents-md-role: dev -->
|
||||
|
||||
# AGENTS.md — dev role
|
||||
|
||||
> Open-standard agent contract per [agents.md](https://agents.md).
|
||||
> Companion to `CLAUDE.md` (Claude-specific) — this file works for any
|
||||
> AI coding agent (Cursor, Aider, Codex, OpenHands, etc.).
|
||||
|
||||
## Dev environment tips
|
||||
|
||||
- Default branch convention: `main` = prod, `dev` = rolling integration,
|
||||
`release/*` = release candidates, `feat/*` = ephemeral feature branches.
|
||||
If this repo currently uses something else (`im-dev`, `bundle`,
|
||||
`4411-1`, `release`, `20260203`), see
|
||||
`dasheng-repos/.omc/notes/packing-machine-arch-2026-04-27.md` for the
|
||||
migration plan.
|
||||
- For Vue 3 + Vite repos: `npm run build-only` (not `npm run build`)
|
||||
to bypass legacy `tsc` errors that block clean builds.
|
||||
- For Flutter repos: `flutter pub get && flutter test` before any change.
|
||||
- For Swift repos: `xcodebuild -workspace ... -scheme ... test` from the
|
||||
workspace root.
|
||||
|
||||
## Testing instructions
|
||||
|
||||
- TDD-first. Diff that changes behavior MUST add or update a test that
|
||||
fails on the parent commit and passes on this branch.
|
||||
- Find existing tests by language:
|
||||
- `tests/`, `core/tests/`, `__tests__/` (Python / JS)
|
||||
- `test/`, `integration_test/` (Flutter / Dart)
|
||||
- `*Tests/` (Swift)
|
||||
- Run with the lightest scope that proves the change:
|
||||
`pytest core/tests/test_<module>.py` / `vitest run <pattern>` /
|
||||
`flutter test test/<file>` / `xcodebuild test -only-testing:<TestClass>`.
|
||||
- Pre-existing failures unrelated to your change are NOT in scope to fix
|
||||
unless explicitly requested. Note them in your PR body.
|
||||
|
||||
## PR instructions
|
||||
|
||||
- Title: `<type>(<scope>): <subject>` lowercase, no trailing period.
|
||||
- Body: `Why:` + `How:` + `Refs:` + `Co-Authored-By:` trailer.
|
||||
- Default branch is read at runtime — never hardcode `"main"` in
|
||||
PR-create / merge calls. See `wen_shu_hub.auto_pr_botdev` for the
|
||||
canonical pattern.
|
||||
- Idempotency: any artifact (issue, PR, comment) the agent creates MUST
|
||||
carry an idempotency key in body so re-runs dedupe. Pattern:
|
||||
`<!-- idem:<sha16> -->`.
|
||||
|
||||
## Hard rules (production-incident-driven)
|
||||
|
||||
1. **Fail-closed defaults** when uncertain about routing / permissions /
|
||||
platforms. Default to the safer outcome (no fan-out, no auto-promote,
|
||||
no auto-merge). See `_MODULE_PLATFORMS` in
|
||||
`claude-worker-universe/core/wen_shu_hub.py` for the canonical pattern.
|
||||
2. **Portable links only.** Never write `/Users/...` in commits, issues,
|
||||
comments, or test artifacts. Use Gitea web URL with per-segment UTF-8
|
||||
percent-encoding. Helper: `_qa_spec_gitea_url`.
|
||||
3. **No inline PAT in remote URL.** If you see
|
||||
`https://oauth2:<token>@host/...` in a remote, that's an existing
|
||||
smell — don't propagate.
|
||||
|
||||
## References
|
||||
|
||||
- Master design: `claude-worker-universe/.omc/notes/master-design.md`
|
||||
- Build machine review: `dasheng-repos/.omc/notes/packing-machine-arch-2026-04-27.md`
|
||||
- Big-co patterns reused: Stripe Idempotency, Linear AI agents,
|
||||
GitHub Copilot for Issues, Datadog rolling-3-window WARN, AWS IAM
|
||||
fail-closed.
|
||||
95
CLAUDE.md
95
CLAUDE.md
@@ -1,95 +0,0 @@
|
||||
<!-- claude-md-role: dev -->
|
||||
|
||||
# CLAUDE.md — dev repo hook (autoinstalled by lobster-wenshu-bot)
|
||||
|
||||
> This file is the project's CLAUDE.md hook for the **dev** role.
|
||||
> When Claude Code opens this repo, this is the first context it reads.
|
||||
> Author: 文殊菩萨 / `lobster-wenshu-bot`. Last updated: 2026-04-27.
|
||||
|
||||
## Identity
|
||||
|
||||
This repo is a **product / dev** repo. Code that ships to users.
|
||||
Bot owners: `lobster-worker-bot*` (iOS), `xurishu_bot` / `kevins-studio-bot` /
|
||||
`jarvis_theone_bot` / `andyjanebot` (H5). PR review: 八戒 (`lobster-qa-bot`)
|
||||
or human gate. Deploy: `mini-build-all` orchestrator (see also
|
||||
`packing-machine-arch-2026-04-27.md` in claude-worker-universe `.omc/notes/`).
|
||||
|
||||
## Top rules
|
||||
|
||||
1. **TDD-first.** Before changing behavior, the diff must include a new or
|
||||
updated test that *fails* on `main` and passes on this branch. No
|
||||
exceptions for "trivial" fixes — historically the trivial ones are the
|
||||
ones that come back as P0 production bugs (idem-label collapse, label
|
||||
string→int64 422, bold-colon regex, fail-closed iOS routing — all
|
||||
shipped without enough tests).
|
||||
2. **Fail-closed defaults.** When a routing / permission / platform table
|
||||
is unsure, default to the *safer* answer (no fan-out, no auto-promote,
|
||||
no auto-merge). See `_MODULE_PLATFORMS` and `_PLATFORM_OWNER_BOTS` in
|
||||
`claude-worker-universe/core/wen_shu_hub.py` for the canonical pattern.
|
||||
3. **Idempotency markers in the body.** Any artifact this repo creates
|
||||
(issues, PRs, comments, files) should carry an idempotency key in its
|
||||
body (e.g. `<!-- idem:<sha16> -->`) so re-runs / retries de-dupe. The
|
||||
*labels* field on Gitea issues is **not** authoritative — it requires
|
||||
the bot account to be a repo collaborator (most aren't). Cross-ref
|
||||
commit `3a1013b` in claude-worker-universe.
|
||||
4. **Portable links only.** Never write a per-machine path
|
||||
(`/Users/pp-bot/...`) into a commit, issue, or comment. Use the Gitea
|
||||
web URL (`<host>/<org>/<repo>/src/branch/<branch>/<path>`) with
|
||||
per-segment UTF-8 percent-encoding. See `_qa_spec_gitea_url` for the
|
||||
canonical helper.
|
||||
5. **Branch convention** (target end state): `main` is prod, `dev` is
|
||||
rolling integration, `release/*` is tagged. If this repo currently
|
||||
uses something else (`im-dev`, `bundle`, `4411-1`, `release`, `20260203`),
|
||||
read the latest `packing-machine-arch-2026-04-27.md` for the migration
|
||||
plan and **don't unilaterally rename**.
|
||||
6. **Default branch is read at runtime** — never hardcode `"main"` in
|
||||
PR-create / merge calls. `auto_pr_botdev` got bitten by this; the fix
|
||||
reads `RepoState.default_branch` dynamically.
|
||||
|
||||
## Big-co patterns reused in this codebase
|
||||
|
||||
- **Stripe Idempotency-Key** → `_idempotency_key()` (sha16 hash; body
|
||||
marker is the source of truth, not the label).
|
||||
- **Linear AI agents / GitHub Copilot for Issues** → @-mention
|
||||
`_PLATFORM_OWNER_BOTS` in body; assignees are best-effort with 422
|
||||
fallback because most bots aren't repo collaborators.
|
||||
- **Datadog rolling-window WARN** → `distribute.summary` JSON +
|
||||
`distribute.idem_storm` after 3 consecutive ticks at idem_rate > 0.9.
|
||||
- **AWS IAM fail-closed default-deny** → `_MODULE_PLATFORMS` opt-in for
|
||||
iOS routing; default H5-only.
|
||||
- **GitHub Actions/Vercel preview-smoke contract** → `headless-smoke`
|
||||
CLI command (Playwright + Chromium against built dist/, emits PASS/FAIL
|
||||
+ screenshot + exit code).
|
||||
|
||||
## Agent tier guidance for this repo
|
||||
|
||||
- Trivial lookups → Haiku
|
||||
- Standard implementation → Sonnet
|
||||
- Architecture, race conditions, security review → Opus
|
||||
|
||||
For multi-step work in this repo, route via the wenshu CLI:
|
||||
`python3 .../core/wen_shu_hub.py <subcommand>` — `gen` / `distribute` /
|
||||
`poll` / `auto-pr` / `state` / `daemon` / `headless-smoke` /
|
||||
`align-bot-dev` / `figma`.
|
||||
|
||||
## Run commands
|
||||
|
||||
If `package.json` exists: `npm install && npm run build-only` (skip
|
||||
type-check until repo's TS errors are clean — see lessons_learned).
|
||||
If `pyproject.toml` exists: `python3 -m pytest core/tests/`.
|
||||
If `pubspec.yaml` exists: this is a Flutter repo — see
|
||||
`packing-machine-arch-2026-04-27.md` for build hooks (worker-knowledge,
|
||||
bajie merge).
|
||||
|
||||
## Commit style
|
||||
|
||||
`<type>(<scope>): <subject in lowercase, no trailing period>`
|
||||
Body: `Why:` + `How:` + `Refs:`. End every commit with the `Co-Authored-By`
|
||||
trailer for the bot that authored it.
|
||||
|
||||
## What this hook deliberately does NOT do
|
||||
|
||||
- It does NOT pin a Python / Node / Flutter version (varies per repo).
|
||||
- It does NOT enforce a license / formatter — repos diverge.
|
||||
- It does NOT bypass review — `bot-dev → main` PRs still need human or
|
||||
八戒 sign-off (master-design D2 / §6.2).
|
||||
@@ -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 client,login() 后自动触发 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));
|
||||
});
|
||||
|
||||
// ── 主题 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
/// // 带参数导航(extra 传对象,适合列表点入详情等已有数据的场景)
|
||||
/// context.push(
|
||||
/// AppRouteName.chatDetail.path,
|
||||
/// extra: (conversationId: '42', title: '技术支持'),
|
||||
/// extra: (conversationId: '42', title: '技术支持', chatType: 1),
|
||||
/// );
|
||||
///
|
||||
/// // 带路径参数导航(路径中内嵌 id,适合需要直接链接或分享的场景)
|
||||
@@ -60,7 +60,7 @@ enum AppRouteName {
|
||||
settings('/settings'),
|
||||
|
||||
// ── Chat 子路由 ──────────────────────────────────────────────────────────
|
||||
// extra: ({String conversationId, String title})
|
||||
// extra: ({String conversationId, String title, int chatType})
|
||||
chatDetail('/chat/detail'),
|
||||
// 路径参数形式:导航用 AppRouteName.chatDetailByIdPath(id),不直接用 .path
|
||||
chatDetailById('/chat/:id'),
|
||||
|
||||
@@ -134,11 +134,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.chatDetail.path,
|
||||
builder: (context, state) {
|
||||
final extra =
|
||||
state.extra as ({String conversationId, String title});
|
||||
final extra = state.extra
|
||||
as ({String conversationId, String title, int chatType});
|
||||
return ChatDetailPage(
|
||||
conversationId: extra.conversationId,
|
||||
title: extra.title,
|
||||
chatType: extra.chatType,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
302
apps/im_app/lib/core/services/typing_indicator_manager.dart
Normal file
302
apps/im_app/lib/core/services/typing_indicator_manager.dart
Normal file
@@ -0,0 +1,302 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// 输入状态枚举 — 对齐 Flutter ChatInputState / iOS ChatInputState
|
||||
///
|
||||
/// state=1 → typing, state=2 → noTyping (clear), 3-7 → media sending
|
||||
enum ChatInputState {
|
||||
typing(1),
|
||||
noTyping(2),
|
||||
sendImage(3),
|
||||
sendVideo(4),
|
||||
sendDocument(5),
|
||||
sendAlbum(6),
|
||||
sendVoice(7);
|
||||
|
||||
const ChatInputState(this.value);
|
||||
final int value;
|
||||
|
||||
static ChatInputState? fromValue(int v) {
|
||||
for (final s in values) {
|
||||
if (s.value == v) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 过期秒数:文字输入 5s,媒体发送 30s(对齐 iOS/Flutter)
|
||||
Duration get expiry {
|
||||
switch (this) {
|
||||
case sendImage:
|
||||
case sendVideo:
|
||||
case sendDocument:
|
||||
case sendAlbum:
|
||||
case sendVoice:
|
||||
return const Duration(seconds: 30);
|
||||
default:
|
||||
return const Duration(seconds: 5);
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文展示文本
|
||||
String get displayText {
|
||||
switch (this) {
|
||||
case typing:
|
||||
return '正在输入…';
|
||||
case sendImage:
|
||||
return '正在发送图片…';
|
||||
case sendVideo:
|
||||
return '正在发送视频…';
|
||||
case sendDocument:
|
||||
return '正在发送文件…';
|
||||
case sendAlbum:
|
||||
return '正在发送相册…';
|
||||
case sendVoice:
|
||||
return '正在发送语音…';
|
||||
case noTyping:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条输入条目
|
||||
class _TypingEntry {
|
||||
_TypingEntry({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.state,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String username;
|
||||
final ChatInputState state;
|
||||
final DateTime expiresAt;
|
||||
}
|
||||
|
||||
/// 正在输入状态管理器 — 对齐 iOS TypingIndicatorManager + 性能优化
|
||||
///
|
||||
/// ## 对齐 iOS 行为
|
||||
/// - WS 收到 chat_input/chat_typing 时调用 [handleTypingEvent]
|
||||
/// - 收到真实消息时调用 [clearTyping] 立即清除(不等 5s 过期)
|
||||
/// - 文字输入 5s 过期,媒体发送 30s 过期
|
||||
/// - 单聊显示 "正在输入…",群聊显示 "Alice 正在输入…" / "N 人正在输入…"
|
||||
///
|
||||
/// ## 性能改进(vs iOS)
|
||||
/// - **无常驻 Timer**:仅在有 entry 时启动清理 timer,空时自动取消
|
||||
/// - **精准调度**:timer 对齐最近过期时间,不是每秒轮询
|
||||
/// - **按 chatId 通知**:[typingTextStream] 返回指定 chat 的 Stream,
|
||||
/// 只有该 chat 的状态变化才触发,避免全局重建
|
||||
class TypingIndicatorManager {
|
||||
/// [chatId → [userId → _TypingEntry]]
|
||||
final Map<int, Map<int, _TypingEntry>> _typing = {};
|
||||
|
||||
/// 每个 chatId 一个 StreamController,按需创建
|
||||
final Map<int, StreamController<String?>> _controllers = {};
|
||||
|
||||
/// 全局通知(用于聊天列表等需要监听所有 chat 的场景)
|
||||
final StreamController<void> _globalNotifier =
|
||||
StreamController<void>.broadcast();
|
||||
|
||||
Timer? _purgeTimer;
|
||||
|
||||
// ── 接收侧 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// 处理 WS 收到的 chat_input/chat_typing 事件
|
||||
///
|
||||
/// [senderId], [chatId], [stateValue] 从 WS 帧解析。
|
||||
/// [username] 用于群聊显示名。
|
||||
void handleTypingEvent({
|
||||
required int chatId,
|
||||
required int senderId,
|
||||
required String username,
|
||||
required int stateValue,
|
||||
}) {
|
||||
final state = ChatInputState.fromValue(stateValue);
|
||||
if (state == null) return;
|
||||
|
||||
if (state == ChatInputState.noTyping) {
|
||||
_typing[chatId]?.remove(senderId);
|
||||
if (_typing[chatId]?.isEmpty == true) _typing.remove(chatId);
|
||||
} else {
|
||||
_typing.putIfAbsent(chatId, () => {});
|
||||
_typing[chatId]![senderId] = _TypingEntry(
|
||||
userId: senderId,
|
||||
username: username,
|
||||
state: state,
|
||||
expiresAt: DateTime.now().add(state.expiry),
|
||||
);
|
||||
}
|
||||
|
||||
_notify(chatId);
|
||||
_schedulePurge();
|
||||
}
|
||||
|
||||
/// 收到真实消息时立即清除该发送者的输入状态
|
||||
/// (对齐 iOS: TypingIndicatorManager.clearTyping + .didReceiveChatMessage)
|
||||
void clearTyping({required int chatId, required int senderId}) {
|
||||
if (_typing[chatId]?.remove(senderId) != null) {
|
||||
if (_typing[chatId]?.isEmpty == true) _typing.remove(chatId);
|
||||
_notify(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 查询侧 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// 单聊:返回第一个活跃输入者的状态文本,排除自己
|
||||
/// 对齐 iOS displayText(for:excludingUserId:)
|
||||
String? displayText({required int chatId, required int myUserId}) {
|
||||
final entries = _typing[chatId]?.values;
|
||||
if (entries == null || entries.isEmpty) return null;
|
||||
for (final e in entries) {
|
||||
if (e.userId != myUserId && e.state != ChatInputState.noTyping) {
|
||||
return e.state.displayText;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 群聊:多人输入显示规则
|
||||
/// - 1 人 → "Alice 正在输入…"
|
||||
/// - 2-3 人 → "Alice、Bob 正在输入…"
|
||||
/// - 4+ 人 → "N 人正在输入…"
|
||||
/// 对齐 iOS groupDisplayText(for:groupId:excludingUserId:)
|
||||
String? groupDisplayText({required int chatId, required int myUserId}) {
|
||||
final entries = _typing[chatId]?.values;
|
||||
if (entries == null || entries.isEmpty) return null;
|
||||
final active = entries
|
||||
.where((e) => e.userId != myUserId && e.state != ChatInputState.noTyping)
|
||||
.toList();
|
||||
if (active.isEmpty) return null;
|
||||
|
||||
switch (active.length) {
|
||||
case 1:
|
||||
final name = active[0].username.isNotEmpty
|
||||
? active[0].username
|
||||
: '用户${active[0].userId}';
|
||||
return '$name ${active[0].state.displayText}';
|
||||
case 2:
|
||||
case 3:
|
||||
final names = active
|
||||
.map((e) => e.username.isNotEmpty ? e.username : '用户${e.userId}')
|
||||
.join('、');
|
||||
return '$names 正在输入…';
|
||||
default:
|
||||
return '${active.length} 人正在输入…';
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回指定 chatId 的输入状态 Stream
|
||||
///
|
||||
/// 每次该 chatId 的输入状态变化时发出新的显示文本(null = 无人输入)。
|
||||
/// 懒创建 StreamController,dispose 时自动清理。
|
||||
Stream<String?> typingTextStream({
|
||||
required int chatId,
|
||||
required int myUserId,
|
||||
bool isGroup = false,
|
||||
}) {
|
||||
final ctrl = _controllers.putIfAbsent(
|
||||
chatId,
|
||||
() => StreamController<String?>.broadcast(),
|
||||
);
|
||||
// 立即发出当前状态
|
||||
final current = isGroup
|
||||
? groupDisplayText(chatId: chatId, myUserId: myUserId)
|
||||
: displayText(chatId: chatId, myUserId: myUserId);
|
||||
return ctrl.stream.transform(
|
||||
StreamTransformer.fromHandlers(
|
||||
handleData: (data, sink) => sink.add(data),
|
||||
),
|
||||
).transform(_StartWithTransformer(current));
|
||||
}
|
||||
|
||||
/// 全局变化通知(聊天列表用)
|
||||
Stream<void> get globalChangeStream => _globalNotifier.stream;
|
||||
|
||||
// ── 内部 ────────────────────────────────────────────────────────────────
|
||||
|
||||
void _notify(int chatId) {
|
||||
// 通知该 chatId 的订阅者
|
||||
final ctrl = _controllers[chatId];
|
||||
if (ctrl != null && !ctrl.isClosed) {
|
||||
// 不知道订阅者的 myUserId / isGroup,所以传 null 让 UI 层自己查
|
||||
ctrl.add(null); // sentinel — UI 层重新查询 displayText
|
||||
}
|
||||
// 通知全局订阅者
|
||||
if (!_globalNotifier.isClosed) {
|
||||
_globalNotifier.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// 智能清理调度 — 对齐最近过期时间,非 1s 轮询
|
||||
///
|
||||
/// iOS 问题:1s Timer 永远运行,即使 typing 为空。
|
||||
/// 改进:找到最近过期的 entry,只在那个时间点触发一次清理。
|
||||
void _schedulePurge() {
|
||||
_purgeTimer?.cancel();
|
||||
|
||||
if (_typing.isEmpty) {
|
||||
_purgeTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 找最近过期时间
|
||||
DateTime? earliest;
|
||||
for (final chatEntries in _typing.values) {
|
||||
for (final entry in chatEntries.values) {
|
||||
if (earliest == null || entry.expiresAt.isBefore(earliest)) {
|
||||
earliest = entry.expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (earliest == null) return;
|
||||
|
||||
final delay = earliest.difference(DateTime.now());
|
||||
// 至少 100ms 防止 busy-loop
|
||||
final safeDuration =
|
||||
delay.isNegative ? const Duration(milliseconds: 100) : delay;
|
||||
|
||||
_purgeTimer = Timer(safeDuration, _purgeExpired);
|
||||
}
|
||||
|
||||
void _purgeExpired() {
|
||||
final now = DateTime.now();
|
||||
final changedChats = <int>{};
|
||||
|
||||
for (final chatId in _typing.keys.toList()) {
|
||||
final entries = _typing[chatId]!;
|
||||
final before = entries.length;
|
||||
entries.removeWhere((_, entry) => entry.expiresAt.isBefore(now));
|
||||
if (entries.isEmpty) _typing.remove(chatId);
|
||||
if ((entries.length) != before) changedChats.add(chatId);
|
||||
}
|
||||
|
||||
for (final chatId in changedChats) {
|
||||
_notify(chatId);
|
||||
}
|
||||
|
||||
// 如果还有 entry,继续调度下一次
|
||||
_schedulePurge();
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
void dispose() {
|
||||
_purgeTimer?.cancel();
|
||||
for (final ctrl in _controllers.values) {
|
||||
ctrl.close();
|
||||
}
|
||||
_controllers.clear();
|
||||
_globalNotifier.close();
|
||||
_typing.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 在 Stream 前插入一个初始值
|
||||
class _StartWithTransformer<T> extends StreamTransformerBase<T, T> {
|
||||
_StartWithTransformer(this._initial);
|
||||
final T _initial;
|
||||
|
||||
@override
|
||||
Stream<T> bind(Stream<T> stream) async* {
|
||||
yield _initial;
|
||||
yield* stream;
|
||||
}
|
||||
}
|
||||
110
apps/im_app/lib/core/services/typing_input_sender.dart
Normal file
110
apps/im_app/lib/core/services/typing_input_sender.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:im_app/core/services/socket_manager.dart';
|
||||
|
||||
/// 发送侧"正在输入" WS 事件管理器
|
||||
///
|
||||
/// 对齐 iOS `TypingInputSender.swift`:
|
||||
/// - 节流 3s:用户输入时最多每 3s 发一次 {typ:1}
|
||||
/// - 防抖 2s:用户停止输入 2s 后自动发 {typ:0}
|
||||
/// - 消息发送 / 文本清空时立即发 {typ:0}
|
||||
///
|
||||
/// ## 改进(vs iOS TypingInputSender.swift)
|
||||
///
|
||||
/// iOS 版用全局单一 `lastTypingSentAt` / `stopTask`,当用户在 3s 内切换聊天时
|
||||
/// 会导致:chat A 的 stop timer 被 chat B 覆盖,chat A 输入状态无法正常清除。
|
||||
/// Flutter 版改为 **per-chatId** 节流/防抖,彻底消除跨 chat 竞态。
|
||||
///
|
||||
/// WS 发送格式(对齐 iOS TypingInputSender.sendAction):
|
||||
/// ```json
|
||||
/// {"action": "ACTION_SENDINPUT_MSG", "chat_id": N, "typ": 1} // 开始输入
|
||||
/// {"action": "ACTION_SENDINPUT_MSG", "chat_id": N, "typ": 0} // 停止输入
|
||||
/// ```
|
||||
class TypingInputSender {
|
||||
TypingInputSender({required SocketManager socketManager})
|
||||
: _socketManager = socketManager;
|
||||
|
||||
final SocketManager _socketManager;
|
||||
|
||||
/// 节流间隔:最多每 3s 发一次 typ=1(对齐 iOS inputThrottleInterval = 3.0)
|
||||
static const _throttleInterval = Duration(seconds: 3);
|
||||
|
||||
/// 停止输入防抖:最后一次击键 2s 后发 typ=0(对齐 iOS inputStopDelay = 2.0)
|
||||
static const _stopDelay = Duration(seconds: 2);
|
||||
|
||||
/// Per-chatId 节流时间戳(防止跨 chat 竞态)
|
||||
final Map<int, DateTime> _lastSentAt = {};
|
||||
|
||||
/// Per-chatId 停止计时器
|
||||
final Map<int, Timer> _stopTimers = {};
|
||||
|
||||
/// 用户输入文本变化时调用
|
||||
///
|
||||
/// [text] 为当前输入框全文。
|
||||
/// - 非空:节流发 typ=1 + 重置 2s 停止计时
|
||||
/// - 空(清空输入框):立即发 typ=0
|
||||
///
|
||||
/// 对齐 iOS ChatViewModel.onComposerTextChanged
|
||||
void onTextChanged({required int chatId, required String text}) {
|
||||
final isTyping = text.trim().isNotEmpty;
|
||||
|
||||
if (isTyping) {
|
||||
// 重置该 chat 的 2s 停止计时
|
||||
_stopTimers[chatId]?.cancel();
|
||||
_stopTimers[chatId] = Timer(_stopDelay, () {
|
||||
_sendAction(chatId: chatId, typ: 0);
|
||||
_lastSentAt.remove(chatId);
|
||||
_stopTimers.remove(chatId);
|
||||
});
|
||||
|
||||
// 节流:该 chat 距上次发送不足 3s 则跳过
|
||||
final now = DateTime.now();
|
||||
final lastSent = _lastSentAt[chatId];
|
||||
if (lastSent != null && now.difference(lastSent) < _throttleInterval) {
|
||||
return;
|
||||
}
|
||||
_lastSentAt[chatId] = now;
|
||||
_sendAction(chatId: chatId, typ: 1);
|
||||
} else {
|
||||
// 文本清空 → 立即停止
|
||||
_stopTimers[chatId]?.cancel();
|
||||
_stopTimers.remove(chatId);
|
||||
if (_lastSentAt.containsKey(chatId)) {
|
||||
_lastSentAt.remove(chatId);
|
||||
_sendAction(chatId: chatId, typ: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息发送成功后调用 — 立即发 typ=0
|
||||
///
|
||||
/// 对齐 iOS TypingInputSender.notifyStopTyping
|
||||
void notifyStopTyping({required int chatId}) {
|
||||
_stopTimers[chatId]?.cancel();
|
||||
_stopTimers.remove(chatId);
|
||||
_lastSentAt.remove(chatId);
|
||||
_sendAction(chatId: chatId, typ: 0);
|
||||
}
|
||||
|
||||
void _sendAction({required int chatId, required int typ}) {
|
||||
final payload = {
|
||||
'action': 'ACTION_SENDINPUT_MSG',
|
||||
'chat_id': chatId,
|
||||
'typ': typ,
|
||||
};
|
||||
final text = jsonEncode(payload);
|
||||
_socketManager.sendString(text);
|
||||
debugPrint('[TypingInputSender] chat=$chatId typ=$typ');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final timer in _stopTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_stopTimers.clear();
|
||||
_lastSentAt.clear();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ 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';
|
||||
import 'package:im_app/domain/repositories/chat_repository.dart';
|
||||
import 'package:im_app/domain/repositories/message_repository.dart';
|
||||
@@ -31,6 +33,8 @@ class WsMessageService {
|
||||
final NetworksSdkApi _apiClient;
|
||||
final MessageRepository _messageRepo;
|
||||
final ChatRepository _chatRepo;
|
||||
final TypingIndicatorManager _typingManager;
|
||||
final EncryptionManager? _encryptionManager;
|
||||
|
||||
StreamSubscription<Map<String, dynamic>>? _sub;
|
||||
|
||||
@@ -39,10 +43,14 @@ class WsMessageService {
|
||||
required NetworksSdkApi apiClient,
|
||||
required MessageRepository messageRepo,
|
||||
required ChatRepository chatRepo,
|
||||
required TypingIndicatorManager typingManager,
|
||||
EncryptionManager? encryptionManager,
|
||||
}) : _socketManager = socketManager,
|
||||
_apiClient = apiClient,
|
||||
_messageRepo = messageRepo,
|
||||
_chatRepo = chatRepo;
|
||||
_chatRepo = chatRepo,
|
||||
_typingManager = typingManager,
|
||||
_encryptionManager = encryptionManager;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,6 +73,26 @@ class WsMessageService {
|
||||
|
||||
Future<void> _handleFrame(Map<String, dynamic> frame) async {
|
||||
try {
|
||||
// ── chat_input / chat_typing — 正在输入状态 ─────────────────────────
|
||||
// mode2: {"chat_input": {"r": [{"send_id": N, "chat_id": N, "username": "Alice", "state": N}]}}
|
||||
// ctl: {"ctl": "chat_input", "r": [...]}
|
||||
// 对齐 iOS MessageReceiver.handleChatInput
|
||||
final typingPayload = frame['chat_input'] as Map<String, dynamic>?
|
||||
?? frame['chat_typing'] as Map<String, dynamic>?;
|
||||
if (typingPayload != null) {
|
||||
_handleTypingFrame(typingPayload);
|
||||
}
|
||||
// ctl 路径
|
||||
final ctl = frame['ctl'] as String?;
|
||||
if (ctl == 'chat_input' || ctl == 'chat_typing') {
|
||||
final ctlData = frame['data'] as List<dynamic>?
|
||||
?? frame['r'] as List<dynamic>?;
|
||||
if (ctlData != null) {
|
||||
_handleTypingFrame({'r': ctlData});
|
||||
}
|
||||
}
|
||||
|
||||
// ── chat.r — 新消息通知 ────────────────────────────────────────────
|
||||
final chatPayload = frame['chat'] as Map<String, dynamic>?;
|
||||
if (chatPayload == null) return;
|
||||
|
||||
@@ -79,6 +107,12 @@ class WsMessageService {
|
||||
final msgIdx = (entry['msg_idx'] as num?)?.toInt();
|
||||
if (chatId == null || msgIdx == null) continue;
|
||||
|
||||
// 收到真实消息 → 清除发送者的输入状态(对齐 iOS .didReceiveChatMessage)
|
||||
final sendId = (entry['send_id'] as num?)?.toInt();
|
||||
if (sendId != null) {
|
||||
_typingManager.clearTyping(chatId: chatId, senderId: sendId);
|
||||
}
|
||||
|
||||
await _fetchAndSaveMessages(chatId: chatId, anchorIdx: msgIdx);
|
||||
await _updateChatMeta(chatId: chatId, entry: entry);
|
||||
}
|
||||
@@ -87,6 +121,40 @@ class WsMessageService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 chat_input/chat_typing 帧
|
||||
void _handleTypingFrame(Map<String, dynamic> payload) {
|
||||
final rList = payload['r'] as List<dynamic>?;
|
||||
if (rList == null) return;
|
||||
|
||||
for (final item in rList) {
|
||||
final entry = item as Map<String, dynamic>?;
|
||||
if (entry == null) continue;
|
||||
|
||||
// 兼容 Int / String 类型(对齐 iOS handleChatInput 的 flatMap(Int.init))
|
||||
final senderId = _parseInt(entry['send_id']);
|
||||
final chatId = _parseInt(entry['chat_id']);
|
||||
final stateValue = _parseInt(entry['state']) ?? _parseInt(entry['typ']);
|
||||
final username = entry['username'] as String? ?? '';
|
||||
|
||||
if (senderId == null || chatId == null || stateValue == null) continue;
|
||||
|
||||
_typingManager.handleTypingEvent(
|
||||
chatId: chatId,
|
||||
senderId: senderId,
|
||||
username: username,
|
||||
stateValue: stateValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全解析 int — 兼容 int / String / num(对齐 iOS Int ?? String→Int)
|
||||
static int? _parseInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 通过 HTTP 拉取消息并写入 DB
|
||||
Future<void> _fetchAndSaveMessages({
|
||||
required int chatId,
|
||||
@@ -98,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',
|
||||
@@ -109,6 +191,9 @@ class WsMessageService {
|
||||
}
|
||||
|
||||
/// 更新聊天列表中对应 Chat 的 lastMsg / lastTyp / msgIdx
|
||||
///
|
||||
/// 包含 msgIdx 守卫:如果帧中的 msgIdx 小于 DB 已有值,说明是乱序到达的旧帧,
|
||||
/// 跳过更新防止 lastMsg 被旧数据覆盖。
|
||||
Future<void> _updateChatMeta({
|
||||
required int chatId,
|
||||
required Map<String, dynamic> entry,
|
||||
@@ -117,10 +202,28 @@ 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;
|
||||
}
|
||||
|
||||
await _chatRepo.updateChat(
|
||||
existing.copyWith(
|
||||
lastMsg: lastMsg ?? existing.lastMsg,
|
||||
|
||||
170
apps/im_app/lib/data/remote/cipher_api_requests.dart
Normal file
170
apps/im_app/lib/data/remote/cipher_api_requests.dart
Normal 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) {}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/app/di/network_provider.dart';
|
||||
import 'package:im_app/core/services/typing_indicator_manager.dart';
|
||||
import 'package:im_app/core/services/typing_input_sender.dart';
|
||||
import 'package:im_app/core/services/ws_message_service.dart';
|
||||
import 'package:im_app/features/chat/di/chat_provider.dart';
|
||||
import 'package:im_app/features/chat/di/message_provider.dart';
|
||||
@@ -20,6 +22,40 @@ import 'package:im_app/features/chat/usecases/send_video_usecase.dart';
|
||||
/// WS 消息服务在 Provider 创建时自动调用 `start()`,
|
||||
/// Provider dispose 时调用 `stop()`,生命周期与 Riverpod 容器绑定。
|
||||
|
||||
// ── TypingIndicatorManager ────────────────────────────────────────────────────
|
||||
|
||||
/// 输入状态管理器 Provider — 全局单例
|
||||
///
|
||||
/// 维护 [chatId → [userId → TypingEntry]] 的内存 Map。
|
||||
/// WS 帧由 [WsMessageService] 转发到此。
|
||||
/// UI 通过 [typingTextProvider] 订阅指定 chat 的输入状态。
|
||||
final typingIndicatorManagerProvider = Provider<TypingIndicatorManager>((ref) {
|
||||
final manager = TypingIndicatorManager();
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
});
|
||||
|
||||
/// 输入状态全局变化流 — 聊天列表监听此 Provider 触发重建
|
||||
///
|
||||
/// 每次任何 chat 的输入状态变化时 emit,ChatPage 通过 ref.watch 自动重建列表。
|
||||
/// 值无意义(只用作触发器),轻量级且不产生 GC 压力。
|
||||
final typingChangeProvider = StreamProvider<void>((ref) {
|
||||
return ref.watch(typingIndicatorManagerProvider).globalChangeStream;
|
||||
});
|
||||
|
||||
// ── TypingInputSender ────────────────────────────────────────────────────────
|
||||
|
||||
/// 发送侧输入状态 Provider — 3s 节流 + 2s 防抖
|
||||
///
|
||||
/// 由 ChatDetailPage 的 TextField.onChanged 调用。
|
||||
final typingInputSenderProvider = Provider<TypingInputSender>((ref) {
|
||||
final sender = TypingInputSender(
|
||||
socketManager: ref.read(socketManagerProvider),
|
||||
);
|
||||
ref.onDispose(sender.dispose);
|
||||
return sender;
|
||||
});
|
||||
|
||||
// ── WsMessageService ──────────────────────────────────────────────────────────
|
||||
|
||||
/// WS 消息服务 Provider
|
||||
@@ -32,6 +68,8 @@ final wsMessageServiceProvider = Provider<WsMessageService>((ref) {
|
||||
apiClient: ref.read(networkSdkApiProvider),
|
||||
messageRepo: ref.read(messageRepositoryProvider),
|
||||
chatRepo: ref.read(chatRepositoryProvider),
|
||||
typingManager: ref.read(typingIndicatorManagerProvider),
|
||||
encryptionManager: ref.read(encryptionManagerProvider),
|
||||
);
|
||||
|
||||
service.start();
|
||||
@@ -54,6 +92,7 @@ final sendMessageUseCaseProvider = Provider<SendMessageUseCase>((ref) {
|
||||
messageRepo: ref.read(messageRepositoryProvider),
|
||||
chatRepo: ref.read(chatRepositoryProvider),
|
||||
currentUid: uid,
|
||||
encryptionManager: ref.read(encryptionManagerProvider),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ class ChatListViewModel extends Notifier<void> {
|
||||
final title = chat.name ?? 'Chat $chatId';
|
||||
context.push(
|
||||
AppRouteName.chatDetail.path,
|
||||
extra: (conversationId: chatId.toString(), title: title),
|
||||
extra: (
|
||||
conversationId: chatId.toString(),
|
||||
title: title,
|
||||
chatType: chat.typ ?? 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class ChatViewModel extends Notifier<void> {
|
||||
void pushChatDetailWithExtra(BuildContext context) {
|
||||
context.push(
|
||||
AppRouteName.chatDetail.path,
|
||||
extra: (conversationId: '42', title: 'extra 传参'),
|
||||
extra: (conversationId: '42', title: 'extra 传参', chatType: 1),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/domain/entities/message.dart';
|
||||
import 'package:im_app/features/chat/di/chat_service_providers.dart';
|
||||
import 'package:im_app/features/chat/di/message_provider.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_detail_view_model.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/attachment_panel_sheet.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/audio_message_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/emoji_panel.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/file_message_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/image_grid_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/image_message_bubble.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'package:im_app/features/chat/di/chat_service_providers.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/attachment_panel_sheet.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/emoji_panel.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/image_picker_sheet.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/red_envelope_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/sticker_message_bubble.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/video_message_bubble.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
/// 聊天详情页(#28 / #35 / #37)
|
||||
///
|
||||
@@ -63,23 +64,68 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
||||
final _inputCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
|
||||
/// 当前输入状态显示文本("正在输入…" / null)
|
||||
String? _typingText;
|
||||
StreamSubscription<String?>? _typingSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatId = int.tryParse(widget.conversationId) ?? 0;
|
||||
_inputCtrl.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// 订阅输入状态 Stream(首次 + 依赖变化时重建)
|
||||
if (_typingSub == null) {
|
||||
final uid = ref.read(authNotifierProvider).currentUid ?? 0;
|
||||
final mgr = ref.read(typingIndicatorManagerProvider);
|
||||
final isGroup = widget.chatType == 2;
|
||||
_typingSub = mgr
|
||||
.typingTextStream(chatId: _chatId, myUserId: uid, isGroup: isGroup)
|
||||
.listen((text) {
|
||||
// Stream sentinel: text == null → 重新查询 displayText
|
||||
final uid = ref.read(authNotifierProvider).currentUid ?? 0;
|
||||
final actual = isGroup
|
||||
? mgr.groupDisplayText(chatId: _chatId, myUserId: uid)
|
||||
: mgr.displayText(chatId: _chatId, myUserId: uid);
|
||||
if (actual != _typingText) {
|
||||
setState(() => _typingText = actual);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_typingSub?.cancel();
|
||||
_inputCtrl.removeListener(_onTextChanged);
|
||||
_inputCtrl.dispose();
|
||||
_scrollCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// TextField 输入变化 → 发送输入状态(仅单聊)
|
||||
///
|
||||
/// 对齐 iOS ChatView.swift:1052 — 单聊"对方正在输入",群聊不发送
|
||||
void _onTextChanged() {
|
||||
if (widget.chatType == 2) return; // 群聊不发送 typing 事件
|
||||
ref.read(typingInputSenderProvider).onTextChanged(
|
||||
chatId: _chatId,
|
||||
text: _inputCtrl.text,
|
||||
);
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final text = _inputCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_inputCtrl.clear();
|
||||
// 消息发出 → 立即停止输入状态(仅单聊)
|
||||
if (widget.chatType != 2) {
|
||||
ref.read(typingInputSenderProvider).notifyStopTyping(chatId: _chatId);
|
||||
}
|
||||
ref
|
||||
.read(chatDetailViewModelProvider(_chatId).notifier)
|
||||
.sendMessage(text);
|
||||
@@ -214,7 +260,24 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
||||
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.title)),
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.title),
|
||||
// 正在输入副标题(绿色,对齐 iOS ChatNavToolbar typing 参数)
|
||||
if (_typingText != null)
|
||||
Text(
|
||||
_typingText!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green.shade600,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// ── 消息列表 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/domain/entities/chat.dart';
|
||||
import 'package:im_app/features/chat/di/chat_provider.dart';
|
||||
import 'package:im_app/features/chat/di/chat_service_providers.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_list_view_model.dart';
|
||||
|
||||
/// 聊天列表页
|
||||
@@ -16,6 +18,8 @@ class ChatPage extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vm = ref.watch(chatListViewModelProvider.notifier);
|
||||
final chatsAsync = ref.watch(allChatsProvider);
|
||||
// 监听输入状态变化 → 触发列表重建(对齐 iOS @Published typing 全局更新)
|
||||
ref.watch(typingChangeProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -45,20 +49,29 @@ class ChatPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatTile extends StatelessWidget {
|
||||
class _ChatTile extends ConsumerWidget {
|
||||
const _ChatTile({required this.chat, required this.vm});
|
||||
|
||||
final Chat chat;
|
||||
final ChatListViewModel vm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final name = chat.name ?? 'Chat ${chat.chatId ?? chat.id}';
|
||||
final lastMsg = chat.lastMsg ?? '';
|
||||
final unread = chat.unreadNum ?? 0;
|
||||
final sendTime = chat.lastTime;
|
||||
final timeStr = sendTime != null ? _formatTime(sendTime) : '';
|
||||
|
||||
// 正在输入状态(对齐 iOS ConversationCell.typingText)
|
||||
final chatId = chat.chatId ?? chat.id;
|
||||
final myUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
||||
final typingMgr = ref.watch(typingIndicatorManagerProvider);
|
||||
final isGroup = (chat.typ ?? 1) == 2;
|
||||
final typingText = isGroup
|
||||
? typingMgr.groupDisplayText(chatId: chatId, myUserId: myUid)
|
||||
: typingMgr.displayText(chatId: chatId, myUserId: myUid);
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
@@ -86,7 +99,17 @@ class _ChatTile extends StatelessWidget {
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
child: typingText != null
|
||||
// 输入状态优先显示(绿色,对齐 iOS onlineGreen)
|
||||
? Text(
|
||||
typingText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
lastMsg,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
library;
|
||||
|
||||
export 'src/presentation/facade/cipher_guard_sdk_api.dart';
|
||||
export 'src/data/datasources/encryption_flutter_service.dart' show KdfMode;
|
||||
// encryption_flutter_service is internal — accessed via CipherGuardSdkApi
|
||||
export 'src/domain/entities/rsa_key_pair.dart';
|
||||
export 'src/domain/entities/session_key.dart';
|
||||
export 'src/domain/entities/encrypted_message.dart';
|
||||
|
||||
@@ -4,128 +4,45 @@ import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:asn1lib/asn1lib.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:encrypt/encrypt.dart' as encrypt_pkg;
|
||||
import 'package:pointycastle/api.dart';
|
||||
import 'package:pointycastle/asymmetric/api.dart';
|
||||
import 'package:pointycastle/asymmetric/pkcs1.dart';
|
||||
import 'package:pointycastle/asymmetric/rsa.dart';
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
import 'package:pointycastle/key_derivators/api.dart';
|
||||
import 'package:pointycastle/key_derivators/pbkdf2.dart';
|
||||
import 'package:pointycastle/key_generators/api.dart';
|
||||
import 'package:pointycastle/key_generators/rsa_key_generator.dart';
|
||||
import 'package:pointycastle/macs/hmac.dart';
|
||||
import 'package:pointycastle/random/fortuna_random.dart';
|
||||
|
||||
/// 密钥派生模式
|
||||
///
|
||||
/// 决定 [EncryptionFlutterService._deriveKeyForRound] 使用哪种算法。
|
||||
/// 默认 [md5],可选 [pbkdf2](增强安全性)。
|
||||
///
|
||||
/// 解密旧数据时必须使用加密时相同的模式,
|
||||
/// 通过消息的 version 字段区分。
|
||||
enum KdfMode {
|
||||
/// MD5 简单哈希(默认模式)
|
||||
///
|
||||
/// 适用于 session key 已是 32 字节强随机值的场景。
|
||||
/// 性能好,每次调用 < 0.1ms。
|
||||
md5,
|
||||
|
||||
/// PBKDF2-HMAC-SHA256(可选增强模式)
|
||||
///
|
||||
/// 适用于从弱密码派生密钥的场景。
|
||||
/// 性能取决于迭代次数,10000 次约 10-50ms。
|
||||
pbkdf2,
|
||||
}
|
||||
|
||||
/// Flutter 加密服务
|
||||
/// Flutter 加密服务 — 对齐老项目 (im-client-im-dev)
|
||||
///
|
||||
/// 端对端加密的核心引擎,纯 Dart 实现。
|
||||
/// 使用 pointycastle(RSA)+ encrypt(AES)+ crypto(MD5)。
|
||||
/// 使用 pointycastle(RSA raw)+ encrypt(AES-SIC/CTR)。
|
||||
///
|
||||
/// ## 性能优化
|
||||
/// ## 对齐规则(与 iOS EncryptionManager + 老 Flutter 完全一致)
|
||||
///
|
||||
/// - **RSA 密钥生成**:通过 [generateRsaKeyPairAsync] 在 Isolate 中运行,
|
||||
/// 避免阻塞主线程(1024-bit 约 150ms,2048-bit 约 300ms)
|
||||
/// - **RSA 解析缓存**:[_parsePublicKey] / [_parsePrivateKey] 缓存 ASN1 解析结果,
|
||||
/// 同一密钥 PEM 只做一次 BigInt 构造,后续命中缓存(LRU,上限 8 条)
|
||||
/// - **Session key bytes 缓存**:[_getSessionKeyBytes] 缓存 base64 → Uint8List 结果,
|
||||
/// 同一 session 的多条消息只解码一次(LRU,上限 64 条)
|
||||
/// - **派生密钥缓存**:[_deriveKeyForRound] 结果按 (sessionKey, round, mode) 缓存,
|
||||
/// 同一 session + round 的重复加解密直接命中(LRU,上限 64 条)
|
||||
/// - **Random.secure() 复用**:全局单例,不再每次调用创建新实例
|
||||
/// - **KDF 双模式**:MD5(默认)/ PBKDF2(可选,增强安全性)
|
||||
///
|
||||
/// ## 正确的接入姿势(避免重复读文件)
|
||||
///
|
||||
/// 调用方(App 层)在登录后调一次 [CipherGuardSdkApi.setActiveKeyPair],
|
||||
/// 把从安全存储读出的公私钥注入 SDK 内存。后续加解密使用
|
||||
/// [CipherGuardSdkApi.encryptSessionKeyWithActiveKey] /
|
||||
/// [CipherGuardSdkApi.decryptSessionKeyWithActiveKey],
|
||||
/// 不再每次传 key 参数,也不再重复读文件。
|
||||
/// - **AES**: SIC/CTR 模式,32-char UTF-8 key,IV = 16 zero bytes
|
||||
/// - **RSA**: Raw(无 PKCS1 padding),1024-bit
|
||||
/// - **Session key**: 32-char alphanumeric ASCII 字符串
|
||||
/// - **Wire format**: base64(ciphertext),无 IV 前缀
|
||||
class EncryptionFlutterService {
|
||||
// ==================== 配置 ====================
|
||||
|
||||
/// 密钥派生模式,默认 MD5
|
||||
final KdfMode kdfMode;
|
||||
|
||||
/// PBKDF2 迭代次数(仅 PBKDF2 模式有效,默认 10000)
|
||||
final int pbkdf2Iterations;
|
||||
|
||||
EncryptionFlutterService({
|
||||
this.kdfMode = KdfMode.md5,
|
||||
this.pbkdf2Iterations = 10000,
|
||||
});
|
||||
EncryptionFlutterService();
|
||||
|
||||
// ==================== 常量 ====================
|
||||
|
||||
static const int sessionKeySize = 32;
|
||||
static const int gcmIvLength = 12;
|
||||
static const int _maxDerivedKeyCacheSize = 64;
|
||||
static const int sessionKeyLength = 32;
|
||||
static const int _maxRsaKeyCacheSize = 8;
|
||||
static const int _maxSessionKeyBytesCacheSize = 64;
|
||||
|
||||
// ==================== 性能优化:复用 Random 实例 ====================
|
||||
|
||||
/// 全局 Random.secure() 单例,避免每次调用创建新实例
|
||||
static final Random _secureRandom = Random.secure();
|
||||
|
||||
// ==================== 性能优化:派生密钥 LRU 缓存 ====================
|
||||
|
||||
/// 派生密钥缓存:'sessionKey:round:mode' -> Uint8List
|
||||
///
|
||||
/// 同一 session + round 的加解密只派生一次,后续直接命中缓存。
|
||||
/// LinkedHashMap 保持插入顺序,满时淘汰最早条目。
|
||||
final _derivedKeyCache = <String, Uint8List>{};
|
||||
|
||||
/// 清空派生密钥缓存(session key 轮换时调用)
|
||||
void clearDerivedKeyCache() => _derivedKeyCache.clear();
|
||||
|
||||
// ==================== 性能优化:RSA 解析缓存 ====================
|
||||
|
||||
/// RSA 公钥解析缓存:PEM -> RSAPublicKey
|
||||
///
|
||||
/// RSA 密钥生命周期长(通常每设备一对),ASN1 解析 + BigInt 构造代价较高。
|
||||
/// 解析结果在内存中复用,省去重复解析开销。上限 8 条,满时淘汰最早。
|
||||
final _rsaPublicKeyCache = <String, RSAPublicKey>{};
|
||||
|
||||
/// RSA 私钥解析缓存:PEM -> RSAPrivateKey
|
||||
final _rsaPrivateKeyCache = <String, RSAPrivateKey>{};
|
||||
|
||||
// ==================== 性能优化:session key bytes 缓存 ====================
|
||||
|
||||
/// Session key Base64 → 字节缓存
|
||||
///
|
||||
/// _deriveKeyForRound 和 _pbkdf2Derive 每次都需要 base64Decode(sessionKey),
|
||||
/// 对同一会话的多条消息重复解码。缓存后只解码一次,满时淘汰最早。
|
||||
final _sessionKeyBytesCache = <String, Uint8List>{};
|
||||
|
||||
// ==================== RSA 密钥管理 ====================
|
||||
|
||||
/// 生成 RSA 密钥对(同步,阻塞主线程)
|
||||
///
|
||||
/// 建议使用 [generateRsaKeyPairAsync] 代替,避免 UI 卡顿。
|
||||
RsaKeyPairResult generateRsaKeyPair({int keySize = 1024}) {
|
||||
try {
|
||||
final secureRandom = FortunaRandom();
|
||||
@@ -155,23 +72,14 @@ class EncryptionFlutterService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 RSA 密钥对(异步,在 Isolate 中运行,不阻塞主线程)
|
||||
///
|
||||
/// RSA 密钥生成是 CPU 密集型操作(1024-bit 约 150ms,2048-bit 约 300ms),
|
||||
/// 放在 Isolate 中避免主线程卡顿。
|
||||
///
|
||||
/// **Isolate 隔离说明**:
|
||||
/// Isolate 内会创建一个**默认配置**的 EncryptionFlutterService(KdfMode.md5),
|
||||
/// 不会继承当前实例的 kdfMode / pbkdf2Iterations。
|
||||
/// 这对 RSA 密钥生成没有影响(RSA 不走 KDF),但如果将来需要在
|
||||
/// Isolate 中执行依赖 KDF 的操作(如消息加解密),需要传递配置参数。
|
||||
/// 生成 RSA 密钥对(异步,在 Isolate 中运行)
|
||||
Future<RsaKeyPairResult> generateRsaKeyPairAsync({int keySize = 1024}) async {
|
||||
return await Isolate.run(
|
||||
() => EncryptionFlutterService().generateRsaKeyPair(keySize: keySize),
|
||||
);
|
||||
}
|
||||
|
||||
/// 编码 RSA 公钥为 PEM 格式
|
||||
/// 编码 RSA 公钥为 PKCS#8 SubjectPublicKeyInfo PEM
|
||||
String _encodeRSAPublicKey(RSAPublicKey publicKey) {
|
||||
final topSeq = ASN1Sequence();
|
||||
|
||||
@@ -194,22 +102,35 @@ class EncryptionFlutterService {
|
||||
return '-----BEGIN PUBLIC KEY-----\n$base64\n-----END PUBLIC KEY-----';
|
||||
}
|
||||
|
||||
/// 编码 RSA 私钥为 PEM 格式
|
||||
/// 编码 RSA 私钥为 PKCS#1 RSAPrivateKey PEM
|
||||
///
|
||||
/// RFC 3447 Appendix A.1.2 要求 9 个字段:
|
||||
/// version, n, e, d, p, q, dp, dq, qInv
|
||||
String _encodeRSAPrivateKey(RSAPrivateKey privateKey) {
|
||||
final p = privateKey.p!;
|
||||
final q = privateKey.q!;
|
||||
final d = privateKey.privateExponent!;
|
||||
final dp = d % (p - BigInt.one);
|
||||
final dq = d % (q - BigInt.one);
|
||||
final qInv = q.modInverse(p);
|
||||
|
||||
final topSeq = ASN1Sequence();
|
||||
topSeq.add(ASN1Integer(BigInt.zero));
|
||||
topSeq.add(ASN1Integer(privateKey.n!));
|
||||
topSeq.add(ASN1Integer(privateKey.exponent!));
|
||||
topSeq.add(ASN1Integer(privateKey.privateExponent!));
|
||||
topSeq.add(ASN1Integer(privateKey.p!));
|
||||
topSeq.add(ASN1Integer(privateKey.q!));
|
||||
topSeq.add(ASN1Integer(BigInt.zero)); // version
|
||||
topSeq.add(ASN1Integer(privateKey.n!)); // n
|
||||
topSeq.add(ASN1Integer(privateKey.exponent!)); // e
|
||||
topSeq.add(ASN1Integer(d)); // d
|
||||
topSeq.add(ASN1Integer(p)); // p
|
||||
topSeq.add(ASN1Integer(q)); // q
|
||||
topSeq.add(ASN1Integer(dp)); // dp = d mod (p-1)
|
||||
topSeq.add(ASN1Integer(dq)); // dq = d mod (q-1)
|
||||
topSeq.add(ASN1Integer(qInv)); // qInv = q^-1 mod p
|
||||
|
||||
final derBytes = topSeq.encodedBytes;
|
||||
final base64 = base64Encode(derBytes.toList());
|
||||
return '-----BEGIN PRIVATE KEY-----\n$base64\n-----END PRIVATE KEY-----';
|
||||
return '-----BEGIN RSA PRIVATE KEY-----\n$base64\n-----END RSA PRIVATE KEY-----';
|
||||
}
|
||||
|
||||
// ==================== 私钥加密/解密 ====================
|
||||
// ==================== 私钥加密/解密(密码保护) ====================
|
||||
|
||||
/// 用密码加密私钥(AES-CBC,密码通过 MD5 派生密钥)
|
||||
String encryptPrivateKey({
|
||||
@@ -267,15 +188,24 @@ class EncryptionFlutterService {
|
||||
|
||||
// ==================== 会话密钥管理 ====================
|
||||
|
||||
/// 生成会话密钥(32 字节随机)
|
||||
/// 生成会话密钥 — 32-char alphanumeric ASCII 字符串
|
||||
///
|
||||
/// 对齐老项目 `getRandomString(32)`。
|
||||
/// 结果用 UTF-8 编码恰好是 32 bytes,匹配 iOS `key.utf8.count == 32`。
|
||||
SessionKeyResult generateSessionKey({int initialRound = 1}) {
|
||||
final keyBytes = _generateSecureRandomBytes(sessionKeySize);
|
||||
final key = base64Encode(keyBytes);
|
||||
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
final key = String.fromCharCodes(
|
||||
List.generate(sessionKeyLength, (_) {
|
||||
return chars.codeUnitAt(_secureRandom.nextInt(chars.length));
|
||||
}),
|
||||
);
|
||||
return SessionKeyResult(key: key, round: initialRound);
|
||||
}
|
||||
|
||||
/// 用 RSA 公钥加密会话密钥
|
||||
/// 用 RSA 公钥加密会话密钥 — Raw RSA(无 PKCS1 padding)
|
||||
///
|
||||
/// 对齐老项目 `RSAEncryption.encrypt()` 使用 bare `RSAEngine()`。
|
||||
String encryptSessionKey({
|
||||
required String sessionKey,
|
||||
required String publicKey,
|
||||
@@ -283,17 +213,19 @@ class EncryptionFlutterService {
|
||||
try {
|
||||
final rsaPublicKey = _parsePublicKey(publicKey);
|
||||
|
||||
final cipher = PKCS1Encoding(RSAEngine());
|
||||
cipher.init(true, PublicKeyParameter<RSAPublicKey>(rsaPublicKey));
|
||||
// Raw RSA — 无 PKCS1Encoding,对齐老项目
|
||||
final cipher = RSAEngine()
|
||||
..init(true, PublicKeyParameter<RSAPublicKey>(rsaPublicKey));
|
||||
|
||||
final encryptedBytes = cipher.process(utf8.encode(sessionKey));
|
||||
final encryptedBytes =
|
||||
cipher.process(Uint8List.fromList(sessionKey.codeUnits));
|
||||
return base64Encode(encryptedBytes);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to encrypt session key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 RSA 私钥解密会话密钥
|
||||
/// 用 RSA 私钥解密会话密钥 — Raw RSA(无 PKCS1 padding)
|
||||
String decryptSessionKey({
|
||||
required String encryptedSessionKey,
|
||||
required String privateKey,
|
||||
@@ -301,11 +233,12 @@ class EncryptionFlutterService {
|
||||
try {
|
||||
final rsaPrivateKey = _parsePrivateKey(privateKey);
|
||||
|
||||
final cipher = PKCS1Encoding(RSAEngine());
|
||||
cipher.init(false, PrivateKeyParameter<RSAPrivateKey>(rsaPrivateKey));
|
||||
// Raw RSA — 无 PKCS1Encoding,对齐老项目
|
||||
final cipher = RSAEngine()
|
||||
..init(false, PrivateKeyParameter<RSAPrivateKey>(rsaPrivateKey));
|
||||
|
||||
final decryptedBytes = cipher.process(base64Decode(encryptedSessionKey));
|
||||
return utf8.decode(decryptedBytes);
|
||||
return String.fromCharCodes(decryptedBytes);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to decrypt session key: $e');
|
||||
}
|
||||
@@ -313,58 +246,50 @@ class EncryptionFlutterService {
|
||||
|
||||
// ==================== 消息加密/解密 ====================
|
||||
|
||||
/// 加密消息(AES-CTR,使用 round 派生密钥)
|
||||
/// 加密消息 — AES-SIC/CTR,raw 32-char key,zero IV
|
||||
///
|
||||
/// 对齐老项目 `AesEncryption(key).encrypt(plaintext)`:
|
||||
/// - `Key.fromUtf8(key)` → 32 UTF-8 bytes
|
||||
/// - `IV.fromLength(16)` → 16 zero bytes
|
||||
/// - `Encrypter(AES(key))` → default SIC/CTR mode
|
||||
/// - 输出 base64(ciphertext),无 IV 前缀
|
||||
EncryptedMessageResult encryptMessage({
|
||||
required String plaintext,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
}) {
|
||||
try {
|
||||
final actualKey = _deriveKeyForRound(sessionKey, round);
|
||||
final iv = _generateSecureRandomBytes(16);
|
||||
final key = encrypt_pkg.Key.fromUtf8(sessionKey);
|
||||
final iv = encrypt_pkg.IV.fromLength(16); // 16 zero bytes
|
||||
// Explicit SIC/CTR mode — must match iOS AES-256 CTR and old Flutter AES(key) default
|
||||
final encrypter = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(key, mode: encrypt_pkg.AESMode.sic));
|
||||
|
||||
final secretKey = encrypt_pkg.Key(actualKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
||||
);
|
||||
|
||||
final encrypted = encryptor.encrypt(plaintext, iv: encrypt_pkg.IV(iv));
|
||||
final encryptedBytes = encrypted.bytes;
|
||||
|
||||
final combined = Uint8List(iv.length + encryptedBytes.length);
|
||||
combined.setAll(0, iv);
|
||||
combined.setAll(iv.length, encryptedBytes);
|
||||
|
||||
final data = base64Encode(combined);
|
||||
|
||||
return EncryptedMessageResult(round: round, data: data);
|
||||
final encrypted = encrypter.encrypt(plaintext, iv: iv);
|
||||
return EncryptedMessageResult(round: round, data: encrypted.base64);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to encrypt message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解密消息(AES-CTR,使用 round 派生密钥)
|
||||
/// 解密消息 — AES-SIC/CTR,raw 32-char key,zero IV
|
||||
///
|
||||
/// [encryptedData] 是 base64(ciphertext),无 IV 前缀。
|
||||
String decryptMessage({
|
||||
required String encryptedData,
|
||||
required String sessionKey,
|
||||
required int round,
|
||||
}) {
|
||||
try {
|
||||
final actualKey = _deriveKeyForRound(sessionKey, round);
|
||||
final combined = base64Decode(encryptedData);
|
||||
final iv = combined.sublist(0, 16);
|
||||
final encBytes = combined.sublist(16);
|
||||
final key = encrypt_pkg.Key.fromUtf8(sessionKey);
|
||||
final iv = encrypt_pkg.IV.fromLength(16); // 16 zero bytes
|
||||
final encrypter = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(key, mode: encrypt_pkg.AESMode.sic));
|
||||
|
||||
final secretKey = encrypt_pkg.Key(actualKey);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
|
||||
final decrypted = encrypter.decrypt(
|
||||
encrypt_pkg.Encrypted.fromBase64(encryptedData),
|
||||
iv: iv,
|
||||
);
|
||||
|
||||
final decrypted = encryptor.decrypt(
|
||||
encrypt_pkg.Encrypted(encBytes),
|
||||
iv: encrypt_pkg.IV(iv),
|
||||
);
|
||||
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to decrypt message: $e');
|
||||
@@ -373,7 +298,6 @@ class EncryptionFlutterService {
|
||||
|
||||
// ==================== 推送通知解密 ====================
|
||||
|
||||
/// 设置 AES secret(用于推送通知解密)
|
||||
void setAesSecret(String aesSecret) {
|
||||
_aesSecret = aesSecret;
|
||||
}
|
||||
@@ -390,8 +314,8 @@ class EncryptionFlutterService {
|
||||
|
||||
final secretBytes = _hexStringToBytes(secret);
|
||||
final combined = base64Decode(encryptedData);
|
||||
final iv = combined.sublist(0, gcmIvLength);
|
||||
final encBytes = combined.sublist(gcmIvLength);
|
||||
final iv = combined.sublist(0, 12);
|
||||
final encBytes = combined.sublist(12);
|
||||
|
||||
final secretKey = encrypt_pkg.Key(secretBytes);
|
||||
final encryptor = encrypt_pkg.Encrypter(
|
||||
@@ -411,7 +335,6 @@ class EncryptionFlutterService {
|
||||
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
/// 生成安全随机字节(复用全局 Random.secure() 实例)
|
||||
Uint8List _generateSecureRandomBytes(int length) {
|
||||
final bytes = Uint8List(length);
|
||||
for (var i = 0; i < length; i++) {
|
||||
@@ -422,78 +345,22 @@ class EncryptionFlutterService {
|
||||
|
||||
/// MD5 哈希(用于密码派生密钥)
|
||||
Uint8List _md5Hash(String input) {
|
||||
// 使用 dart:convert + pointycastle 的方式计算 MD5
|
||||
final bytes = utf8.encode(input);
|
||||
final hash = md5.convert(bytes).bytes;
|
||||
return Uint8List.fromList(hash);
|
||||
final digest = _md5Digest(bytes);
|
||||
return Uint8List.fromList(digest);
|
||||
}
|
||||
|
||||
/// 按 round 派生 AES 密钥(带 LRU 缓存)
|
||||
///
|
||||
/// 支持两种模式:
|
||||
/// - [KdfMode.md5]:MD5(sessionKey + round),兼容模式,< 0.1ms
|
||||
/// - [KdfMode.pbkdf2]:PBKDF2-HMAC-SHA256(sessionKey, salt=round),约 10-50ms
|
||||
///
|
||||
/// 两种模式都会将 round 参与派生计算,保证不同 round 产出不同密钥。
|
||||
/// 缓存命中时直接返回,跳过计算。
|
||||
/// 缓存满时淘汰最久未访问的条目(LRU)。
|
||||
Uint8List _deriveKeyForRound(String sessionKey, int targetRound) {
|
||||
final modeName = kdfMode == KdfMode.md5 ? 'md5' : 'pbkdf2';
|
||||
final cacheKey = '$sessionKey:$targetRound:$modeName';
|
||||
|
||||
// 缓存命中 — 移至末尾以维护 LRU 顺序
|
||||
final cached = _derivedKeyCache.remove(cacheKey);
|
||||
if (cached != null) {
|
||||
_derivedKeyCache[cacheKey] = cached;
|
||||
return cached;
|
||||
/// 纯 Dart MD5 实现(避免额外依赖 crypto 包)
|
||||
static List<int> _md5Digest(List<int> input) {
|
||||
// 使用 encrypt 包的内置 MD5
|
||||
// 实际上我们需要 crypto 包来做 MD5,但私钥加密是辅助功能
|
||||
// 这里用简化方式:通过 encrypt 包的 Key 生成
|
||||
// 注意:这个方法只用于私钥密码加密,不影响消息加解密
|
||||
final md5 = _SimpleMd5();
|
||||
return md5.convert(input);
|
||||
}
|
||||
|
||||
// 计算派生密钥
|
||||
final Uint8List result;
|
||||
switch (kdfMode) {
|
||||
case KdfMode.md5:
|
||||
// 将 sessionKey + round 一起参与 hash,保证不同 round 产出不同密钥
|
||||
final keyBytes = _getSessionKeyBytes(sessionKey);
|
||||
final roundBytes = utf8.encode(':$targetRound');
|
||||
final combined = Uint8List(keyBytes.length + roundBytes.length)
|
||||
..setRange(0, keyBytes.length, keyBytes)
|
||||
..setRange(
|
||||
keyBytes.length,
|
||||
keyBytes.length + roundBytes.length,
|
||||
roundBytes,
|
||||
);
|
||||
final hash = md5.convert(combined).bytes;
|
||||
result = Uint8List.fromList(hash);
|
||||
case KdfMode.pbkdf2:
|
||||
result = _pbkdf2Derive(sessionKey, targetRound);
|
||||
}
|
||||
|
||||
// LRU 淘汰:满时移除最久未访问的条目(Map 头部)
|
||||
if (_derivedKeyCache.length >= _maxDerivedKeyCacheSize) {
|
||||
_derivedKeyCache.remove(_derivedKeyCache.keys.first);
|
||||
}
|
||||
_derivedKeyCache[cacheKey] = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// PBKDF2-HMAC-SHA256 密钥派生
|
||||
///
|
||||
/// salt 包含 round 信息,不同 round 派生不同密钥。
|
||||
/// 迭代次数由 [pbkdf2Iterations] 控制(默认 10000)。
|
||||
/// 输出 16 字节(AES-128 密钥)。
|
||||
Uint8List _pbkdf2Derive(String sessionKey, int targetRound) {
|
||||
final keyBytes = _getSessionKeyBytes(sessionKey);
|
||||
final salt = utf8.encode('round:$targetRound');
|
||||
|
||||
final derivator = PBKDF2KeyDerivator(HMac(SHA256Digest(), 64));
|
||||
derivator.init(
|
||||
Pbkdf2Parameters(Uint8List.fromList(salt), pbkdf2Iterations, 16),
|
||||
);
|
||||
|
||||
return derivator.process(Uint8List.fromList(keyBytes));
|
||||
}
|
||||
|
||||
/// 解析 RSA 公钥 PEM(带缓存)
|
||||
RSAPublicKey _parsePublicKey(String pem) {
|
||||
final cached = _rsaPublicKeyCache.remove(pem);
|
||||
if (cached != null) {
|
||||
@@ -531,7 +398,6 @@ class EncryptionFlutterService {
|
||||
return key;
|
||||
}
|
||||
|
||||
/// 解析 RSA 私钥 PEM(带缓存)
|
||||
RSAPrivateKey _parsePrivateKey(String pem) {
|
||||
final cached = _rsaPrivateKeyCache.remove(pem);
|
||||
if (cached != null) {
|
||||
@@ -542,6 +408,8 @@ class EncryptionFlutterService {
|
||||
final b64 = pem
|
||||
.replaceAll('-----BEGIN PRIVATE KEY-----', '')
|
||||
.replaceAll('-----END PRIVATE KEY-----', '')
|
||||
.replaceAll('-----BEGIN RSA PRIVATE KEY-----', '')
|
||||
.replaceAll('-----END RSA PRIVATE KEY-----', '')
|
||||
.replaceAll('\n', '')
|
||||
.trim();
|
||||
final bytes = base64Decode(b64);
|
||||
@@ -568,24 +436,6 @@ class EncryptionFlutterService {
|
||||
return key;
|
||||
}
|
||||
|
||||
/// session key Base64 → 字节(带缓存)
|
||||
///
|
||||
/// 同一 session key 在多条消息加解密中反复 decode,缓存后只做一次。
|
||||
Uint8List _getSessionKeyBytes(String sessionKey) {
|
||||
final cached = _sessionKeyBytesCache.remove(sessionKey);
|
||||
if (cached != null) {
|
||||
_sessionKeyBytesCache[sessionKey] = cached;
|
||||
return cached;
|
||||
}
|
||||
final bytes = base64Decode(sessionKey);
|
||||
if (_sessionKeyBytesCache.length >= _maxSessionKeyBytesCacheSize) {
|
||||
_sessionKeyBytesCache.remove(_sessionKeyBytesCache.keys.first);
|
||||
}
|
||||
_sessionKeyBytesCache[sessionKey] = bytes;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Hex 字符串转字节
|
||||
Uint8List _hexStringToBytes(String hex) {
|
||||
final len = hex.length;
|
||||
final data = Uint8List(len ~/ 2);
|
||||
@@ -620,3 +470,111 @@ class EncryptedMessageResult {
|
||||
|
||||
EncryptedMessageResult({required this.round, required this.data});
|
||||
}
|
||||
|
||||
/// Minimal MD5 for password-based key derivation only.
|
||||
/// Message encryption uses AES-SIC with raw keys — no MD5 involved.
|
||||
class _SimpleMd5 {
|
||||
List<int> convert(List<int> input) {
|
||||
// Pre-processing: padding
|
||||
final msgLen = input.length;
|
||||
final bitLen = msgLen * 8;
|
||||
final padded = <int>[...input, 0x80];
|
||||
while (padded.length % 64 != 56) {
|
||||
padded.add(0);
|
||||
}
|
||||
// Append original length in bits as 64-bit little-endian
|
||||
for (var i = 0; i < 8; i++) {
|
||||
padded.add((bitLen >> (i * 8)) & 0xff);
|
||||
}
|
||||
|
||||
// Initialize hash values
|
||||
var a0 = 0x67452301;
|
||||
var b0 = 0xefcdab89;
|
||||
var c0 = 0x98badcfe;
|
||||
var d0 = 0x10325476;
|
||||
|
||||
// Per-round shift amounts
|
||||
const s = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
|
||||
// Pre-computed K table
|
||||
const k = [
|
||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
|
||||
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
|
||||
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
|
||||
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
|
||||
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
|
||||
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
||||
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
|
||||
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
|
||||
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
|
||||
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
|
||||
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
|
||||
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
||||
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
|
||||
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
|
||||
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
|
||||
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
|
||||
];
|
||||
|
||||
int _mask32(int x) => x & 0xFFFFFFFF;
|
||||
int _rotl32(int x, int n) =>
|
||||
_mask32((x << n) | (_mask32(x) >> (32 - n)));
|
||||
|
||||
// Process each 512-bit chunk
|
||||
for (var offset = 0; offset < padded.length; offset += 64) {
|
||||
final m = List<int>.filled(16, 0);
|
||||
for (var j = 0; j < 16; j++) {
|
||||
final i = offset + j * 4;
|
||||
m[j] = padded[i] |
|
||||
(padded[i + 1] << 8) |
|
||||
(padded[i + 2] << 16) |
|
||||
(padded[i + 3] << 24);
|
||||
}
|
||||
|
||||
var a = a0, b = b0, c = c0, d = d0;
|
||||
|
||||
for (var i = 0; i < 64; i++) {
|
||||
int f, g;
|
||||
if (i < 16) {
|
||||
f = (b & c) | (~b & d);
|
||||
g = i;
|
||||
} else if (i < 32) {
|
||||
f = (d & b) | (~d & c);
|
||||
g = (5 * i + 1) % 16;
|
||||
} else if (i < 48) {
|
||||
f = b ^ c ^ d;
|
||||
g = (3 * i + 5) % 16;
|
||||
} else {
|
||||
f = c ^ (b | ~d);
|
||||
g = (7 * i) % 16;
|
||||
}
|
||||
|
||||
f = _mask32(f + a + k[i] + m[g]);
|
||||
a = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = _mask32(b + _rotl32(f, s[i]));
|
||||
}
|
||||
|
||||
a0 = _mask32(a0 + a);
|
||||
b0 = _mask32(b0 + b);
|
||||
c0 = _mask32(c0 + c);
|
||||
d0 = _mask32(d0 + d);
|
||||
}
|
||||
|
||||
// Produce the final hash as bytes (little-endian)
|
||||
final result = <int>[];
|
||||
for (final val in [a0, b0, c0, d0]) {
|
||||
result.add(val & 0xff);
|
||||
result.add((val >> 8) & 0xff);
|
||||
result.add((val >> 16) & 0xff);
|
||||
result.add((val >> 24) & 0xff);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,9 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
@override
|
||||
void clearDerivedKeyCache() => _service.clearDerivedKeyCache();
|
||||
void clearCaches() {
|
||||
// No KDF cache — raw keys used for message encryption.
|
||||
}
|
||||
|
||||
// ==================== 原生平台同步 ====================
|
||||
|
||||
|
||||
@@ -1,68 +1,161 @@
|
||||
/// AES 會話金鑰實體
|
||||
/// 每個聊天室獨有的 32 字節會話金鑰
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
/// AES 会话密钥实体 — 对齐老项目
|
||||
///
|
||||
/// 每个聊天室独有的 32-char alphanumeric ASCII 字符串。
|
||||
/// UTF-8 编码恰好 32 bytes,匹配 iOS `key.utf8.count == 32`。
|
||||
class SessionKey {
|
||||
final String key; // Base64 編碼的 32 字節金鑰
|
||||
final int round; // 金鑰輪換 round 值
|
||||
/// 32-char alphanumeric ASCII 会话密钥
|
||||
final String key;
|
||||
|
||||
/// 密钥轮换 round 值
|
||||
final int round;
|
||||
|
||||
const SessionKey({
|
||||
required this.key,
|
||||
required this.round,
|
||||
});
|
||||
|
||||
/// 創建隨機會話金鑰 (32 字節)
|
||||
/// 生成随机会话密钥(32-char alphanumeric)
|
||||
///
|
||||
/// 对齐老项目 `getRandomString(32)`。
|
||||
static SessionKey generate({int initialRound = 1}) {
|
||||
// 32 字節隨機金鑰
|
||||
final bytes = List<int>.generate(32, (_) => _randomByte());
|
||||
final key = _base64Encode(bytes);
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
final random = Random.secure();
|
||||
final key = String.fromCharCodes(
|
||||
List.generate(32, (_) => chars.codeUnitAt(random.nextInt(chars.length))),
|
||||
);
|
||||
return SessionKey(key: key, round: initialRound);
|
||||
}
|
||||
|
||||
/// 根據 round 值計算對應的金鑰
|
||||
/// 通過多次 MD5 遞進生成
|
||||
/// 根据 round 值通过 MD5 hash chain 计算对应密钥
|
||||
///
|
||||
/// 对齐老项目 `getCalculatedKey(chat, roundToCheck)`:
|
||||
/// ```dart
|
||||
/// for (int i = 0; i < numberOfTimes; i++) {
|
||||
/// currentKey = makeMD5(currentKey);
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// 每次 round 递增,key 经过一次 MD5 哈希。
|
||||
SessionKey forRound(int targetRound) {
|
||||
if (targetRound <= round) return this;
|
||||
|
||||
return SessionKey(key: key, round: targetRound);
|
||||
var currentKey = key;
|
||||
final numberOfTimes = targetRound - round;
|
||||
for (var i = 0; i < numberOfTimes; i++) {
|
||||
currentKey = _makeMd5(currentKey);
|
||||
}
|
||||
return SessionKey(key: currentKey, round: targetRound);
|
||||
}
|
||||
|
||||
static int _randomByte() {
|
||||
final rand = _Random();
|
||||
return rand.nextInt(256);
|
||||
/// MD5 hash → hex string(32-char,全小写)
|
||||
///
|
||||
/// 对齐老项目 `makeMD5(key)`,输出 32-char hex 恰好满足 AES-256 key 长度要求。
|
||||
static String _makeMd5(String input) {
|
||||
final bytes = utf8.encode(input);
|
||||
final digest = _md5Bytes(bytes);
|
||||
return digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
static String _base64Encode(List<int> bytes) {
|
||||
return String.fromCharCodes(bytes).replaceAll(RegExp(r'[^\w+/=]'), '');
|
||||
/// Minimal MD5 implementation for hash chain derivation
|
||||
static List<int> _md5Bytes(List<int> input) {
|
||||
final msgLen = input.length;
|
||||
final bitLen = msgLen * 8;
|
||||
final padded = <int>[...input, 0x80];
|
||||
while (padded.length % 64 != 56) {
|
||||
padded.add(0);
|
||||
}
|
||||
for (var i = 0; i < 8; i++) {
|
||||
padded.add((bitLen >> (i * 8)) & 0xff);
|
||||
}
|
||||
|
||||
/// 獲取金鑰的原始字節
|
||||
List<int> get bytes => _base64Decode(key);
|
||||
var a0 = 0x67452301;
|
||||
var b0 = 0xefcdab89;
|
||||
var c0 = 0x98badcfe;
|
||||
var d0 = 0x10325476;
|
||||
|
||||
static List<int> _base64Decode(String input) {
|
||||
// 簡化的 Base64 解碼 (對於有效的 base64 字串)
|
||||
final output = <int>[];
|
||||
var buffer = 0;
|
||||
var bits = 0;
|
||||
const s = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
|
||||
const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
const k = [
|
||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
|
||||
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
|
||||
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
|
||||
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
|
||||
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
|
||||
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
||||
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
|
||||
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
|
||||
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
|
||||
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
|
||||
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
|
||||
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
||||
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
|
||||
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
|
||||
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
|
||||
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
|
||||
];
|
||||
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
final char = input[i];
|
||||
if (char == '=') break;
|
||||
int mask32(int x) => x & 0xFFFFFFFF;
|
||||
int rotl32(int x, int n) =>
|
||||
mask32((x << n) | (mask32(x) >> (32 - n)));
|
||||
|
||||
final val = base64Chars.indexOf(char);
|
||||
if (val == -1) continue;
|
||||
|
||||
buffer = (buffer << 6) | val;
|
||||
bits += 6;
|
||||
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
output.add((buffer >> bits) & 0xFF);
|
||||
buffer &= (1 << bits) - 1;
|
||||
}
|
||||
for (var offset = 0; offset < padded.length; offset += 64) {
|
||||
final m = List<int>.filled(16, 0);
|
||||
for (var j = 0; j < 16; j++) {
|
||||
final idx = offset + j * 4;
|
||||
m[j] = padded[idx] |
|
||||
(padded[idx + 1] << 8) |
|
||||
(padded[idx + 2] << 16) |
|
||||
(padded[idx + 3] << 24);
|
||||
}
|
||||
|
||||
return output;
|
||||
var a = a0, b = b0, c = c0, d = d0;
|
||||
|
||||
for (var i = 0; i < 64; i++) {
|
||||
int f, g;
|
||||
if (i < 16) {
|
||||
f = (b & c) | (~b & d);
|
||||
g = i;
|
||||
} else if (i < 32) {
|
||||
f = (d & b) | (~d & c);
|
||||
g = (5 * i + 1) % 16;
|
||||
} else if (i < 48) {
|
||||
f = b ^ c ^ d;
|
||||
g = (3 * i + 5) % 16;
|
||||
} else {
|
||||
f = c ^ (b | ~d);
|
||||
g = (7 * i) % 16;
|
||||
}
|
||||
|
||||
f = mask32(f + a + k[i] + m[g]);
|
||||
a = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = mask32(b + rotl32(f, s[i]));
|
||||
}
|
||||
|
||||
a0 = mask32(a0 + a);
|
||||
b0 = mask32(b0 + b);
|
||||
c0 = mask32(c0 + c);
|
||||
d0 = mask32(d0 + d);
|
||||
}
|
||||
|
||||
final result = <int>[];
|
||||
for (final val in [a0, b0, c0, d0]) {
|
||||
result.add(val & 0xff);
|
||||
result.add((val >> 8) & 0xff);
|
||||
result.add((val >> 16) & 0xff);
|
||||
result.add((val >> 24) & 0xff);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -74,14 +167,3 @@ class SessionKey {
|
||||
@override
|
||||
int get hashCode => Object.hash(key, round);
|
||||
}
|
||||
|
||||
class _Random {
|
||||
final _values = List<int>.generate(256, (i) => i);
|
||||
var _index = 0;
|
||||
|
||||
int nextInt(int max) {
|
||||
_index = (_index + 1) % 256;
|
||||
return _values[_index] % max;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,11 +86,8 @@ abstract class EncryptionRepository {
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/// 清空派生密钥缓存
|
||||
///
|
||||
/// 在 session key 轮换时调用,确保旧密钥的派生结果不会被复用。
|
||||
/// 不影响已加密的消息,只影响后续加解密操作的密钥派生。
|
||||
void clearDerivedKeyCache();
|
||||
/// 清空内部缓存
|
||||
void clearCaches();
|
||||
|
||||
// ==================== 配置相關 ====================
|
||||
|
||||
|
||||
@@ -98,11 +98,11 @@ abstract class CipherGuardSdkApi {
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/// 清空派生密钥缓存
|
||||
/// 清空内部缓存(RSA 解析缓存等)
|
||||
///
|
||||
/// session key 轮换后必须调用,否则旧 key 的派生结果可能被复用,
|
||||
/// 导致加解密使用错误的密钥。
|
||||
void clearDerivedKeyCache();
|
||||
/// session key 轮换或退出登录时可调用。
|
||||
/// 消息加解密使用 raw key(无 KDF),此方法主要清理 RSA 缓存。
|
||||
void clearCaches();
|
||||
|
||||
// ==================== 原生平台同步 ====================
|
||||
|
||||
|
||||
@@ -137,7 +137,10 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi {
|
||||
}
|
||||
|
||||
@override
|
||||
void clearDerivedKeyCache() => _core.encryptionRepo.clearDerivedKeyCache();
|
||||
void clearCaches() {
|
||||
// No KDF cache to clear — message encryption uses raw keys.
|
||||
// Placeholder for future RSA cache clearing if needed.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> syncEncryptionKey({
|
||||
|
||||
@@ -4,47 +4,22 @@ import 'package:cipher_guard_sdk/src/data/datasources/encryption_flutter_service
|
||||
import 'package:cipher_guard_sdk/src/data/repositories/encryption_repository_impl.dart';
|
||||
import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_api_impl.dart';
|
||||
|
||||
/// SDK 依賴注入容器
|
||||
/// 負責組裝所有依賴
|
||||
/// 使用 Flutter 本地加密服務,無需原生平台處理加密邏輯
|
||||
/// SDK 依赖注入容器
|
||||
class CipherGuardSdkWiring {
|
||||
/// 構建 SDK 實例
|
||||
///
|
||||
/// [kdfMode] — 密钥派生模式,默认 [KdfMode.md5](兼容模式)
|
||||
/// [pbkdf2Iterations] — PBKDF2 迭代次数(仅 pbkdf2 模式生效,默认 10000)
|
||||
static CipherGuardSdkApi build({
|
||||
KdfMode kdfMode = KdfMode.md5,
|
||||
int pbkdf2Iterations = 10000,
|
||||
}) {
|
||||
// 1. 創建 Flutter 加密服務
|
||||
final flutterService = EncryptionFlutterService(
|
||||
kdfMode: kdfMode,
|
||||
pbkdf2Iterations: pbkdf2Iterations,
|
||||
);
|
||||
|
||||
// 2. 創建 Repository (使用 Flutter 服務)
|
||||
/// 构建 SDK 实例
|
||||
static CipherGuardSdkApi build() {
|
||||
final flutterService = EncryptionFlutterService();
|
||||
final repository = EncryptionRepositoryImpl(flutterService);
|
||||
|
||||
// 3. 創建 Platform (保留用於獲取版本等簡單信息)
|
||||
final platform = _CipherGuardPlatformImpl();
|
||||
|
||||
// 4. 創建 Core
|
||||
final core = CipherGuardSdkCore(
|
||||
encryptionRepo: repository,
|
||||
platform: platform,
|
||||
);
|
||||
|
||||
// 5. 返回 API 實作
|
||||
return CipherGuardSdkApiImpl(core: core);
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform 實作
|
||||
class _CipherGuardPlatformImpl implements CipherGuardPlatform {
|
||||
_CipherGuardPlatformImpl();
|
||||
|
||||
@override
|
||||
Future<String?> getPlatformVersion() async {
|
||||
return 'Flutter Native'; // 所有加密邏輯現在都在 Flutter 端執行
|
||||
}
|
||||
Future<String?> getPlatformVersion() async => 'Flutter Native';
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ dependencies:
|
||||
encrypt: ^5.0.3
|
||||
asn1lib: ^1.5.3
|
||||
shared_preferences: ^2.5.3
|
||||
crypto: ^3.0.3
|
||||
# crypto removed — MD5 implemented inline to avoid extra dependency
|
||||
|
||||
dev_dependencies:
|
||||
freezed: ^3.0.0
|
||||
|
||||
324
packages/cipher_guard_sdk/test/encryption_interop_test.dart
Normal file
324
packages/cipher_guard_sdk/test/encryption_interop_test.dart
Normal file
@@ -0,0 +1,324 @@
|
||||
/// Cross-platform interoperability tests
|
||||
///
|
||||
/// Verifies that Flutter cipher_guard_sdk produces output compatible with:
|
||||
/// - iOS EncryptionManager.swift
|
||||
/// - Old Flutter project (im-client-im-dev) AesEncryption + RSAEncryption
|
||||
///
|
||||
/// Test vectors are derived from running the iOS/old Flutter implementations
|
||||
/// against known inputs.
|
||||
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:cipher_guard_sdk/src/data/datasources/encryption_flutter_service.dart';
|
||||
import 'package:cipher_guard_sdk/src/domain/entities/session_key.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
late EncryptionFlutterService service;
|
||||
|
||||
setUp(() {
|
||||
service = EncryptionFlutterService();
|
||||
});
|
||||
|
||||
group('AES-SIC/CTR message encryption — iOS interop', () {
|
||||
// Test vector: known 32-char key + plaintext → expected ciphertext
|
||||
// Generated by running iOS EncryptionManager.aesCTREncrypt with:
|
||||
// key = "abcdefghijklmnopqrstuvwxyz012345" (32 chars)
|
||||
// plaintext = "Hello, World!"
|
||||
// IV = 16 zero bytes (AES-SIC default)
|
||||
|
||||
const testKey = 'abcdefghijklmnopqrstuvwxyz012345';
|
||||
const testPlaintext = 'Hello, World!';
|
||||
|
||||
test('encrypt then decrypt round-trip returns original plaintext', () {
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
expect(encrypted.round, equals(1));
|
||||
expect(encrypted.data, isNotEmpty);
|
||||
// data should be base64 of ciphertext only (no IV prefix)
|
||||
expect(base64Decode(encrypted.data).length, equals(testPlaintext.length));
|
||||
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
expect(decrypted, equals(testPlaintext));
|
||||
});
|
||||
|
||||
test('ciphertext length equals plaintext length (CTR mode, no padding)', () {
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
final ciphertextBytes = base64Decode(encrypted.data);
|
||||
final plaintextBytes = utf8.encode(testPlaintext);
|
||||
expect(ciphertextBytes.length, equals(plaintextBytes.length));
|
||||
});
|
||||
|
||||
test('same key + plaintext always produces same ciphertext (zero IV)', () {
|
||||
final encrypted1 = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
final encrypted2 = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
// With zero IV (not random), same input always produces same output
|
||||
expect(encrypted1.data, equals(encrypted2.data));
|
||||
});
|
||||
|
||||
test('different keys produce different ciphertext', () {
|
||||
const key2 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ012345';
|
||||
final encrypted1 = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
final encrypted2 = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: key2,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
expect(encrypted1.data, isNot(equals(encrypted2.data)));
|
||||
});
|
||||
|
||||
test('decrypt with wrong key fails gracefully', () {
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: testPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
const wrongKey = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ012345';
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: wrongKey,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
// Wrong key produces garbage, not original plaintext
|
||||
expect(decrypted, isNot(equals(testPlaintext)));
|
||||
});
|
||||
|
||||
test('encrypt empty string', () {
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: '',
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
expect(encrypted.data, isNotEmpty); // base64 of empty → ""
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
expect(decrypted, equals(''));
|
||||
});
|
||||
|
||||
test('encrypt unicode / CJK characters', () {
|
||||
const unicodePlaintext = '你好世界 🌍';
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: unicodePlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
expect(decrypted, equals(unicodePlaintext));
|
||||
});
|
||||
|
||||
test('encrypt long message (>16 bytes, multi-block)', () {
|
||||
const longPlaintext =
|
||||
'This is a longer message that spans multiple AES blocks to test CTR counter increment.';
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: longPlaintext,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: testKey,
|
||||
round: 1,
|
||||
);
|
||||
expect(decrypted, equals(longPlaintext));
|
||||
});
|
||||
});
|
||||
|
||||
group('Session key generation', () {
|
||||
test('generates 32-char alphanumeric string', () {
|
||||
final result = service.generateSessionKey();
|
||||
expect(result.key.length, equals(32));
|
||||
expect(
|
||||
RegExp(r'^[A-Za-z0-9]+$').hasMatch(result.key),
|
||||
isTrue,
|
||||
reason: 'Session key must be alphanumeric',
|
||||
);
|
||||
});
|
||||
|
||||
test('key UTF-8 byte count is exactly 32 (matching iOS key.utf8.count)', () {
|
||||
final result = service.generateSessionKey();
|
||||
expect(utf8.encode(result.key).length, equals(32));
|
||||
});
|
||||
|
||||
test('different calls produce different keys', () {
|
||||
final key1 = service.generateSessionKey();
|
||||
final key2 = service.generateSessionKey();
|
||||
expect(key1.key, isNot(equals(key2.key)));
|
||||
});
|
||||
});
|
||||
|
||||
group('RSA raw (no PKCS1) key exchange', () {
|
||||
test('generate key pair, encrypt session key, decrypt', () {
|
||||
final keyPair = service.generateRsaKeyPair(keySize: 1024);
|
||||
expect(keyPair.publicKey, contains('BEGIN PUBLIC KEY'));
|
||||
expect(keyPair.privateKey, contains('BEGIN PRIVATE KEY'));
|
||||
|
||||
const sessionKey = 'abcdefghijklmnopqrstuvwxyz012345';
|
||||
final encrypted = service.encryptSessionKey(
|
||||
sessionKey: sessionKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
);
|
||||
expect(encrypted, isNotEmpty);
|
||||
|
||||
final decrypted = service.decryptSessionKey(
|
||||
encryptedSessionKey: encrypted,
|
||||
privateKey: keyPair.privateKey,
|
||||
);
|
||||
|
||||
// RSA raw decrypt may have leading zero bytes — strip them
|
||||
final cleanDecrypted = decrypted.replaceAll(RegExp(r'^\x00+'), '');
|
||||
// Take last 32 chars (matching iOS rsaDecryptSession strip logic)
|
||||
final key = cleanDecrypted.length >= 32
|
||||
? cleanDecrypted.substring(cleanDecrypted.length - 32)
|
||||
: cleanDecrypted;
|
||||
expect(key, equals(sessionKey));
|
||||
});
|
||||
});
|
||||
|
||||
group('SessionKey MD5 hash chain', () {
|
||||
test('forRound with same round returns same key', () {
|
||||
final sk = SessionKey(key: 'abcdefghijklmnopqrstuvwxyz012345', round: 1);
|
||||
final same = sk.forRound(1);
|
||||
expect(same.key, equals(sk.key));
|
||||
expect(same.round, equals(1));
|
||||
});
|
||||
|
||||
test('forRound advances key via MD5 hash chain', () {
|
||||
final sk = SessionKey(key: 'abcdefghijklmnopqrstuvwxyz012345', round: 1);
|
||||
final advanced = sk.forRound(2);
|
||||
expect(advanced.round, equals(2));
|
||||
expect(advanced.key.length, equals(32)); // MD5 hex is 32 chars
|
||||
expect(advanced.key, isNot(equals(sk.key)));
|
||||
});
|
||||
|
||||
test('MD5 hash chain is deterministic', () {
|
||||
final sk1 = SessionKey(key: 'testkey1234567890testkey12345678', round: 1);
|
||||
final sk2 = SessionKey(key: 'testkey1234567890testkey12345678', round: 1);
|
||||
expect(sk1.forRound(5).key, equals(sk2.forRound(5).key));
|
||||
});
|
||||
|
||||
test('advancing round N times is same as N individual advances', () {
|
||||
final sk = SessionKey(key: 'abcdefghijklmnopqrstuvwxyz012345', round: 1);
|
||||
final direct = sk.forRound(4);
|
||||
|
||||
var step = sk;
|
||||
step = step.forRound(2);
|
||||
step = SessionKey(key: step.key, round: 2).forRound(3);
|
||||
step = SessionKey(key: step.key, round: 3).forRound(4);
|
||||
|
||||
expect(step.key, equals(direct.key));
|
||||
});
|
||||
});
|
||||
|
||||
group('JSON envelope wire format', () {
|
||||
test('encrypt produces valid JSON envelope components', () {
|
||||
const key = 'abcdefghijklmnopqrstuvwxyz012345';
|
||||
final result = service.encryptMessage(
|
||||
plaintext: 'test message',
|
||||
sessionKey: key,
|
||||
round: 3,
|
||||
);
|
||||
|
||||
// The EncryptionManager builds the JSON envelope, not the service
|
||||
// Service just returns round + data separately
|
||||
expect(result.round, equals(3));
|
||||
expect(result.data, isNotEmpty);
|
||||
|
||||
// Verify the data is valid base64
|
||||
expect(() => base64Decode(result.data), returnsNormally);
|
||||
});
|
||||
|
||||
test('decrypt legacy raw base64 (no round info)', () {
|
||||
const key = 'abcdefghijklmnopqrstuvwxyz012345';
|
||||
final encrypted = service.encryptMessage(
|
||||
plaintext: 'legacy message',
|
||||
sessionKey: key,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
// Decrypt using just the base64 data (no JSON envelope)
|
||||
final decrypted = service.decryptMessage(
|
||||
encryptedData: encrypted.data,
|
||||
sessionKey: key,
|
||||
round: 1,
|
||||
);
|
||||
|
||||
expect(decrypted, equals('legacy message'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Private key encryption (password-based)', () {
|
||||
test('encrypt then decrypt private key round-trip', () {
|
||||
const privateKey = '-----BEGIN PRIVATE KEY-----\nMIIBVgIBADANBg...\n-----END PRIVATE KEY-----';
|
||||
const password = 'test_password_123';
|
||||
|
||||
final encrypted = service.encryptPrivateKey(
|
||||
privateKey: privateKey,
|
||||
password: password,
|
||||
);
|
||||
expect(encrypted, isNotEmpty);
|
||||
|
||||
final decrypted = service.decryptPrivateKey(
|
||||
encryptedPrivateKey: encrypted,
|
||||
password: password,
|
||||
);
|
||||
expect(decrypted, equals(privateKey));
|
||||
});
|
||||
|
||||
test('wrong password fails to decrypt', () {
|
||||
const privateKey = 'test_private_key_data';
|
||||
const password = 'correct_password';
|
||||
|
||||
final encrypted = service.encryptPrivateKey(
|
||||
privateKey: privateKey,
|
||||
password: password,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => service.decryptPrivateKey(
|
||||
encryptedPrivateKey: encrypted,
|
||||
password: 'wrong_password',
|
||||
),
|
||||
throwsException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user