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

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

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

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

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

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

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

View File

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

View File

@@ -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 实现。
/// 使用 pointycastleRSA+ encryptAES+ cryptoMD5)。
/// 使用 pointycastleRSA raw+ encryptAES-SIC/CTR)。
///
/// ## 性能优化
/// ## 对齐规则(与 iOS EncryptionManager + 老 Flutter 完全一致)
///
/// - **RSA 密钥生成**:通过 [generateRsaKeyPairAsync] 在 Isolate 中运行,
/// 避免阻塞主线程1024-bit 约 150ms2048-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 keyIV = 16 zero bytes
/// - **RSA**: Raw无 PKCS1 padding1024-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 约 150ms2048-bit 约 300ms
/// 放在 Isolate 中避免主线程卡顿。
///
/// **Isolate 隔离说明**
/// Isolate 内会创建一个**默认配置**的 EncryptionFlutterServiceKdfMode.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/CTRraw 32-char keyzero 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/CTRraw 32-char keyzero 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;
}
// 计算派生密钥
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;
/// 纯 Dart MD5 实现(避免额外依赖 crypto 包
static List<int> _md5Digest(List<int> input) {
// 使用 encrypt 包的内置 MD5
// 实际上我们需要 crypto 包来做 MD5但私钥加密是辅助功能
// 这里用简化方式:通过 encrypt 包的 Key 生成
// 注意:这个方法只用于私钥密码加密,不影响消息加解密
final md5 = _SimpleMd5();
return md5.convert(input);
}
/// 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;
}
}

View File

@@ -108,7 +108,9 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
// ==================== 缓存管理 ====================
@override
void clearDerivedKeyCache() => _service.clearDerivedKeyCache();
void clearCaches() {
// No KDF cache — raw keys used for message encryption.
}
// ==================== 原生平台同步 ====================

View File

@@ -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);
}
static int _randomByte() {
final rand = _Random();
return rand.nextInt(256);
}
static String _base64Encode(List<int> bytes) {
return String.fromCharCodes(bytes).replaceAll(RegExp(r'[^\w+/=]'), '');
}
/// 獲取金鑰的原始字節
List<int> get bytes => _base64Decode(key);
static List<int> _base64Decode(String input) {
// 簡化的 Base64 解碼 (對於有效的 base64 字串)
final output = <int>[];
var buffer = 0;
var bits = 0;
const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
for (var i = 0; i < input.length; i++) {
final char = input[i];
if (char == '=') break;
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;
}
var currentKey = key;
final numberOfTimes = targetRound - round;
for (var i = 0; i < numberOfTimes; i++) {
currentKey = _makeMd5(currentKey);
}
return output;
return SessionKey(key: currentKey, round: targetRound);
}
/// MD5 hash → hex string32-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();
}
/// 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);
}
var a0 = 0x67452301;
var b0 = 0xefcdab89;
var c0 = 0x98badcfe;
var d0 = 0x10325476;
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 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)));
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);
}
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;
}
}

View File

@@ -86,11 +86,8 @@ abstract class EncryptionRepository {
// ==================== 缓存管理 ====================
/// 清空派生密钥缓存
///
/// 在 session key 轮换时调用,确保旧密钥的派生结果不会被复用。
/// 不影响已加密的消息,只影响后续加解密操作的密钥派生。
void clearDerivedKeyCache();
/// 清空内部缓存
void clearCaches();
// ==================== 配置相關 ====================

View File

@@ -98,11 +98,11 @@ abstract class CipherGuardSdkApi {
// ==================== 缓存管理 ====================
/// 清空派生密钥缓存
/// 清空内部缓存RSA 解析缓存等)
///
/// session key 轮换后必须调用,否则旧 key 的派生结果可能被复用,
/// 导致加解密使用错误的密钥
void clearDerivedKeyCache();
/// session key 轮换或退出登录时可调用。
/// 消息加解密使用 raw key无 KDF此方法主要清理 RSA 缓存
void clearCaches();
// ==================== 原生平台同步 ====================

View File

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

View File

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

View File

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

View 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,
);
});
});
}