468 lines
15 KiB
Dart
468 lines
15 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
class ProtocolHelper {
|
|
static const String prefix = "@S:";
|
|
static const String gPrefix = "@G:";
|
|
|
|
static const String typeNack = "NACK";
|
|
static const String typeSym = "SYM";
|
|
static const String typeSfra = "SFRA";
|
|
static const String typeNorm = "NORM";
|
|
static const String typeKeyInit = "KI";
|
|
static const String typeKeyReply = "KR";
|
|
static const String typeAsym = "AE";
|
|
static const String typeAsymFrag = "AF";
|
|
|
|
static const int maxSmsChars = 136;
|
|
static const int fragmentHeaderBytes = 6;
|
|
static const int fragmentChunkBytes = 90;
|
|
|
|
static String encodeTransportPayload(List<int> bytes) {
|
|
return base64UrlEncode(bytes).replaceAll('=', '');
|
|
}
|
|
|
|
static List<int> decodeTransportPayload(String value) {
|
|
// Parity with Python _repair_base64 and SecureCryptoHelper.b64uDecode
|
|
// 1. Map common mangles including space-to-plus
|
|
String clean = value.replaceAll(' ', '+');
|
|
|
|
// 2. Map mangled separators to standard Base64 characters
|
|
clean =
|
|
clean.replaceAll('|', '/').replaceAll(';', '/').replaceAll('!', '/');
|
|
|
|
// 3. Keep ONLY valid Base64/Base64URL characters
|
|
clean = clean.replaceAll(RegExp(r'[^A-Za-z0-9+/=\-_]'), '');
|
|
|
|
// 4. Standardize alphabet (Base64URL -> Standard Base64 for the decoder)
|
|
clean = clean.replaceAll('-', '+').replaceAll('_', '/');
|
|
|
|
// 5. Recalculate padding
|
|
clean = clean.split('=')[0];
|
|
final missingPadding = (4 - (clean.length % 4)) % 4;
|
|
return base64.decode(clean + ('=' * missingPadding));
|
|
}
|
|
|
|
static String _b64Encode(List<int> bytes) => encodeTransportPayload(bytes);
|
|
static List<int> _b64Decode(String value) => decodeTransportPayload(value);
|
|
|
|
static List<String> _transportCandidates(String value) {
|
|
final normalized = value
|
|
.replaceAll('¡', '@')
|
|
.replaceAll('¡', '@')
|
|
.replaceAll('ö', '|')
|
|
.replaceAll('ö', '|')
|
|
.replaceAll('§', '|')
|
|
.replaceAll('§', '|')
|
|
.replaceAll(RegExp(r'\s+'), '');
|
|
|
|
final candidates = <String>{normalized};
|
|
final separatorPattern = RegExp(r'[|;!]');
|
|
final matches = separatorPattern.allMatches(normalized).toList();
|
|
if (matches.isEmpty) return candidates.toList();
|
|
|
|
final replacementOptions = ['', '-', '_'];
|
|
final maxRepairs = matches.length > 4 ? 4 : matches.length;
|
|
|
|
void build(int index, String current, int offset) {
|
|
if (index >= maxRepairs) {
|
|
candidates.add(current);
|
|
return;
|
|
}
|
|
|
|
final match = matches[index];
|
|
final start = match.start + offset;
|
|
final end = match.end + offset;
|
|
for (final replacement in replacementOptions) {
|
|
final next =
|
|
current.substring(0, start) + replacement + current.substring(end);
|
|
final nextOffset =
|
|
offset + replacement.length - (match.end - match.start);
|
|
build(index + 1, next, nextOffset);
|
|
}
|
|
}
|
|
|
|
build(0, normalized, 0);
|
|
return candidates.toList();
|
|
}
|
|
|
|
static Map<String, dynamic>? _parseAsymmetricFragmentPayload(
|
|
String encoded, {
|
|
required bool isGroup,
|
|
}) {
|
|
for (final candidate in _transportCandidates(encoded)) {
|
|
try {
|
|
final raw = _b64Decode(candidate);
|
|
if (raw.length < 6) continue;
|
|
final packetId = raw
|
|
.sublist(0, 4)
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
.join();
|
|
return {
|
|
"type": "afrag",
|
|
"isGroup": isGroup,
|
|
"packetId": packetId,
|
|
"totalParts": raw[4],
|
|
"partNo": raw[5],
|
|
"chunk": _b64Encode(raw.sublist(6))
|
|
};
|
|
} catch (_) {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static String buildSymmetricMsg(String encryptedPayload) {
|
|
return "$prefix$typeSym|$encryptedPayload";
|
|
}
|
|
|
|
static String buildAsymmetricMsg(String b64Payload) {
|
|
return "$prefix$typeAsym|$b64Payload";
|
|
}
|
|
|
|
static String buildSymmetricFrag(
|
|
String packetId, int partNo, int totalParts, String chunk) {
|
|
return "$prefix$typeSfra|$packetId|$partNo|$totalParts|$chunk";
|
|
}
|
|
|
|
static String buildNormalMode() {
|
|
return "$prefix$typeNorm|";
|
|
}
|
|
|
|
static String buildKeyInit(String publicKeyPayload) {
|
|
return "$prefix$typeKeyInit|$publicKeyPayload";
|
|
}
|
|
|
|
static String buildKeyReply(String publicKeyPayload) {
|
|
return "$prefix$typeKeyReply|$publicKeyPayload";
|
|
}
|
|
|
|
static List<String> buildAsymmetricFrames(List<int> payloadBytes,
|
|
{String? packetId}) {
|
|
final single = "$prefix$typeAsym|${_b64Encode(payloadBytes)}";
|
|
if (single.length <= maxSmsChars) {
|
|
return [single];
|
|
}
|
|
return buildAsymmetricFragments(payloadBytes, packetId: packetId);
|
|
}
|
|
|
|
static List<String> buildAsymmetricFragments(List<int> payloadBytes,
|
|
{String? packetId}) {
|
|
final random = Random();
|
|
final packetRaw = packetId != null
|
|
? Uint8List.fromList(List<int>.generate(
|
|
packetId.length ~/ 2,
|
|
(index) => int.parse(packetId.substring(index * 2, index * 2 + 2),
|
|
radix: 16),
|
|
))
|
|
: Uint8List.fromList(List<int>.generate(4, (_) => random.nextInt(256)));
|
|
if (packetRaw.length != 4) {
|
|
throw StateError('Asymmetric packet ids must contain exactly 4 bytes.');
|
|
}
|
|
|
|
final total = (payloadBytes.length / fragmentChunkBytes).ceil();
|
|
if (total > 255) {
|
|
throw StateError('Payload is too large for SMS fragmentation.');
|
|
}
|
|
final frames = <String>[];
|
|
for (var i = 0; i < total; i++) {
|
|
final start = i * fragmentChunkBytes;
|
|
final end = min(start + fragmentChunkBytes, payloadBytes.length);
|
|
final chunk = payloadBytes.sublist(start, end);
|
|
final raw = Uint8List.fromList([
|
|
...packetRaw,
|
|
total,
|
|
i + 1,
|
|
...chunk,
|
|
]);
|
|
frames.add("$prefix$typeAsymFrag|${_b64Encode(raw)}");
|
|
}
|
|
return frames;
|
|
}
|
|
|
|
static Map<String, dynamic> parseMessage(String rawText) {
|
|
if (rawText.isEmpty) return {"type": "plain", "body": ""};
|
|
|
|
// Step 1: Preliminary cleaning (Mojibake Fix & Control Code Removal)
|
|
// Remove all non-printable control characters immediately
|
|
String cleaned = rawText.replaceAll(RegExp(r'[\x00-\x1F\x7F-\x9F]'), '');
|
|
|
|
// Parity with Python: DO NOT trim(). Only remove newlines and nulls at the end.
|
|
// This preserves trailing spaces that might be mangled '+' characters.
|
|
String body = cleaned.replaceAll(RegExp(r'[\x00\r\n]+$'), '');
|
|
|
|
// Mojibake Fix
|
|
body = body
|
|
.replaceAll('¡', '@')
|
|
.replaceAll('¡', '@')
|
|
.replaceAll('ö', '|')
|
|
.replaceAll('ö', '|')
|
|
.replaceAll('§', '|')
|
|
.replaceAll('§', '|')
|
|
.replaceAll('¿', '?')
|
|
.replaceAll('¿', '?');
|
|
|
|
// Fast-path for legacy symmetric fragments. This avoids falling through to
|
|
// generic SYM/b1 parsing when the SMS transport slightly damages the
|
|
// prefix but the SFRA structure is still intact.
|
|
final directSfra = RegExp(
|
|
r'SFRA\s*[|;!ö§]\s*([^|;!ö§\s]+)\s*[|;!ö§]\s*(\d+)\s*[|;!ö§]\s*(\d+)\s*[|;!ö§](.*)$',
|
|
caseSensitive: false,
|
|
).firstMatch(body);
|
|
if (directSfra != null) {
|
|
final lowerBodyForGroup = body.toUpperCase();
|
|
final rawChunk = directSfra.group(4) ?? '';
|
|
return {
|
|
"type": "sfra",
|
|
"isGroup": lowerBodyForGroup.contains('@G:') ||
|
|
lowerBodyForGroup.contains('G|'),
|
|
"packetId": (directSfra.group(1) ?? '').trim(),
|
|
"partNo": int.tryParse((directSfra.group(2) ?? '').trim()) ?? 1,
|
|
"totalParts": int.tryParse((directSfra.group(3) ?? '').trim()) ?? 1,
|
|
"chunk": rawChunk.replaceAll(RegExp(r'\s+'), ''),
|
|
};
|
|
}
|
|
|
|
final directAf = RegExp(
|
|
r'AF\s*[|;!ö§]\s*(.+)$',
|
|
caseSensitive: false,
|
|
).firstMatch(body);
|
|
if (directAf != null) {
|
|
final lowerBodyForGroup = body.toUpperCase();
|
|
final parsedAf = _parseAsymmetricFragmentPayload(
|
|
directAf.group(1) ?? '',
|
|
isGroup: lowerBodyForGroup.contains('@G:') ||
|
|
lowerBodyForGroup.contains('G|'),
|
|
);
|
|
if (parsedAf != null) {
|
|
return parsedAf;
|
|
}
|
|
}
|
|
|
|
// Step 2: Protocol Search
|
|
final protocolTypes = [
|
|
typeSfra,
|
|
typeSym,
|
|
typeAsym,
|
|
typeAsymFrag,
|
|
typeNorm,
|
|
typeKeyInit,
|
|
typeKeyReply,
|
|
typeNack
|
|
];
|
|
|
|
// Structural delimiters (Mainly | ; ! but also ö and § as fallbacks)
|
|
final sepRegex = RegExp(r'[|;!ö§]');
|
|
|
|
int startIdx = -1;
|
|
String? detectedType;
|
|
bool isGroup = false;
|
|
|
|
// A. Standard Header Search (@S: or @G:) - Case Insensitive
|
|
final lowerBody = body.toLowerCase();
|
|
final sIdx = lowerBody.indexOf(prefix.toLowerCase());
|
|
final gIdx = lowerBody.indexOf(gPrefix.toLowerCase());
|
|
|
|
if (sIdx != -1 || gIdx != -1) {
|
|
isGroup = gIdx != -1 && (sIdx == -1 || gIdx < sIdx);
|
|
startIdx = isGroup ? gIdx : sIdx;
|
|
final actualPrefixLength = isGroup ? gPrefix.length : prefix.length;
|
|
|
|
final contentAfterPrefix =
|
|
body.substring(startIdx + actualPrefixLength).trim();
|
|
final lowerContent = contentAfterPrefix.toLowerCase();
|
|
|
|
for (final t in protocolTypes) {
|
|
if (lowerContent.startsWith(t.toLowerCase())) {
|
|
detectedType = t;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
debugPrint(
|
|
'[PROTOCOL] Search Header: detected=$detectedType, startIdx=$startIdx, isGroup=$isGroup');
|
|
|
|
// B. Fuzzy Fallback (Marker + Separator)
|
|
if (detectedType == null) {
|
|
final fuzzyProtocolTypes = [typeSfra, typeSym, typeAsymFrag];
|
|
for (final t in fuzzyProtocolTypes) {
|
|
// Broaden the separator character set for fuzzy matching
|
|
final fuzzyRegex =
|
|
RegExp('${t.toLowerCase()}\\s*[|;!ö§:]', caseSensitive: false);
|
|
final match = fuzzyRegex.firstMatch(body);
|
|
if (match != null) {
|
|
detectedType = t;
|
|
startIdx = max(0, match.start - 3);
|
|
final lookback = body.substring(0, match.start).toUpperCase();
|
|
isGroup = lookback.contains('@G:') || lookback.contains('G|');
|
|
break;
|
|
}
|
|
}
|
|
debugPrint(
|
|
'[PROTOCOL] Search Fuzzy: detected=$detectedType, startIdx=$startIdx, isGroup=$isGroup');
|
|
}
|
|
|
|
// C. Raw Variant Fallback (b1: or h1:)
|
|
if (detectedType == null) {
|
|
final normalizedBody = body.trimLeft();
|
|
final lower = normalizedBody.toLowerCase();
|
|
if (lower.startsWith('b1:') || lower.startsWith('h1:')) {
|
|
return {
|
|
"type": "sym",
|
|
"isGroup": false,
|
|
"payload": normalizedBody.trim()
|
|
};
|
|
}
|
|
return {"type": "plain", "body": rawText};
|
|
}
|
|
|
|
// Step 3: Extract Payload from First Structural Separator
|
|
// Find the first occurrence of detectedType after startIdx
|
|
final typeStartPos =
|
|
body.toUpperCase().indexOf(detectedType.toUpperCase(), startIdx);
|
|
if (typeStartPos == -1) return {"type": "plain", "body": rawText};
|
|
|
|
final sepMatch = sepRegex.firstMatch(body.substring(typeStartPos));
|
|
if (sepMatch == null) {
|
|
if (detectedType == typeKeyInit ||
|
|
detectedType == typeKeyReply ||
|
|
detectedType == typeSym) {
|
|
return {
|
|
"type": detectedType.toLowerCase(),
|
|
"isGroup": isGroup,
|
|
"payload": body.substring(typeStartPos + detectedType.length).trim()
|
|
};
|
|
}
|
|
return {"type": "plain", "body": rawText};
|
|
}
|
|
|
|
final firstSepIdxInSubstring = sepMatch.start;
|
|
final firstSepIdxInBody = typeStartPos + firstSepIdxInSubstring;
|
|
|
|
// Crucial difference: Keep the absolute raw content without trimming to prevent truncation of trailing Base64 spaces (mangled '+')
|
|
final absoluteRawContent = body.substring(firstSepIdxInBody + 1);
|
|
final rawContent = absoluteRawContent.trim();
|
|
final sepChar = sepMatch.group(0)!;
|
|
final parts = [detectedType, ...rawContent.split(sepChar)];
|
|
|
|
debugPrint(
|
|
'[PROTOCOL] Parsed: type=$detectedType, isGroup=$isGroup, sepChar=$sepChar, partsCount=${parts.length}, rawLen=${body.length}');
|
|
if (detectedType == typeSfra) {
|
|
debugPrint('[PROTOCOL] Fragment Debug: parts=$parts');
|
|
}
|
|
|
|
// Type Handlers
|
|
if (detectedType == typeNorm) return {"type": "norm", "isGroup": isGroup};
|
|
|
|
if (detectedType == typeSym) {
|
|
// Support both: SYM|payload AND SYM|packetId|payload
|
|
if (parts.length >= 3) {
|
|
return {
|
|
"type": "sym",
|
|
"isGroup": isGroup,
|
|
"packetId": parts[1].trim(),
|
|
"payload": parts.sublist(2).join(sepChar)
|
|
};
|
|
}
|
|
return {"type": "sym", "isGroup": isGroup, "payload": absoluteRawContent};
|
|
}
|
|
|
|
if (detectedType == typeSfra && parts.length >= 4) {
|
|
final packetId = parts[1].trim();
|
|
final partNo = int.tryParse(parts[2].trim()) ?? 1;
|
|
final totalParts = int.tryParse(parts[3].trim()) ?? 1;
|
|
|
|
// Accurate Chunk Extraction: skip exactly 3 delimiters in absoluteRawContent
|
|
int dCount = 0;
|
|
int chunkStartPos = 0;
|
|
for (int i = 0; i < absoluteRawContent.length; i++) {
|
|
if (absoluteRawContent[i] == sepChar) {
|
|
dCount++;
|
|
if (dCount == 3) {
|
|
chunkStartPos = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Preserve suspicious separator noise inside chunks so the crypto layer
|
|
// can try repair candidates instead of silently dropping real data.
|
|
final String rawChunk = (chunkStartPos > 0)
|
|
? absoluteRawContent.substring(chunkStartPos)
|
|
: (parts.length > 4 ? parts.sublist(4).join(sepChar) : "");
|
|
final String chunk = rawChunk.replaceAll(RegExp(r'\s+'), '');
|
|
|
|
return {
|
|
"type": "sfra",
|
|
"isGroup": isGroup,
|
|
"packetId": packetId,
|
|
"partNo": partNo,
|
|
"totalParts": totalParts,
|
|
"chunk": chunk
|
|
};
|
|
}
|
|
|
|
if (detectedType == typeNack && parts.length >= 3) {
|
|
return {
|
|
"type": "nack",
|
|
"isGroup": isGroup,
|
|
"packetId": parts[1],
|
|
"missingPart": int.tryParse(parts[2]) ?? 1
|
|
};
|
|
}
|
|
|
|
if (detectedType == typeKeyInit) {
|
|
return {
|
|
"type": "key_init",
|
|
"isGroup": isGroup,
|
|
"payload": absoluteRawContent
|
|
};
|
|
}
|
|
if (detectedType == typeKeyReply) {
|
|
return {
|
|
"type": "key_reply",
|
|
"isGroup": isGroup,
|
|
"payload": absoluteRawContent
|
|
};
|
|
}
|
|
if (detectedType == typeAsym) {
|
|
if (parts.length >= 3) {
|
|
return {
|
|
"type": "asym",
|
|
"isGroup": isGroup,
|
|
"packetId": parts[1].trim(),
|
|
"payload": parts.sublist(2).join(sepChar)
|
|
};
|
|
}
|
|
return {
|
|
"type": "asym",
|
|
"isGroup": isGroup,
|
|
"payload": absoluteRawContent
|
|
};
|
|
}
|
|
|
|
if (detectedType == typeSym) {
|
|
final payload = parts.length > 1 ? parts.sublist(1).join(sepChar) : "";
|
|
return {
|
|
"type": "sym",
|
|
"isGroup": isGroup,
|
|
"payload": payload.replaceAll(RegExp(r'\s+'), '')
|
|
};
|
|
}
|
|
|
|
if (detectedType == typeAsymFrag) {
|
|
final parsedAf = _parseAsymmetricFragmentPayload(
|
|
absoluteRawContent,
|
|
isGroup: isGroup,
|
|
);
|
|
if (parsedAf != null) {
|
|
return parsedAf;
|
|
}
|
|
}
|
|
|
|
return {"type": "plain", "body": rawText};
|
|
}
|
|
}
|