Saba-dart/lib/utils/protocol_helper.dart
2026-04-13 23:41:27 +03:30

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