fix(security): close critical findings #1-3 from issue #17
- TLS: validate cert chain by default; debug-menu toggle to disable - Logs: redact secrets in sender/dispatcher payloads - Identity: per-install mt_instanceid/deviceId, per-launch clientSessionId
This commit is contained in:
@@ -5,6 +5,7 @@ import '../core/config/config.dart';
|
|||||||
import '../core/config/countries.dart';
|
import '../core/config/countries.dart';
|
||||||
import '../core/protocol/opcode_map.dart';
|
import '../core/protocol/opcode_map.dart';
|
||||||
import '../core/protocol/packet.dart';
|
import '../core/protocol/packet.dart';
|
||||||
|
import '../core/storage/device_identity.dart';
|
||||||
import '../core/storage/spoofing_service.dart';
|
import '../core/storage/spoofing_service.dart';
|
||||||
import '../core/transport/connection.dart';
|
import '../core/transport/connection.dart';
|
||||||
import '../core/transport/dispatcher.dart';
|
import '../core/transport/dispatcher.dart';
|
||||||
@@ -170,7 +171,7 @@ class Api {
|
|||||||
String timezone = timeZoneName.identifier;
|
String timezone = timeZoneName.identifier;
|
||||||
String locale = 'ru';
|
String locale = 'ru';
|
||||||
String deviceLocale = Platform.localeName.substring(0, 2);
|
String deviceLocale = Platform.localeName.substring(0, 2);
|
||||||
String deviceId = 'a1b2c3d4e5f6a7b8';
|
String deviceId = await DeviceIdentity.deviceId();
|
||||||
|
|
||||||
if (Platform.isLinux) {
|
if (Platform.isLinux) {
|
||||||
final linuxInfo = await deviceInfo.linuxInfo;
|
final linuxInfo = await deviceInfo.linuxInfo;
|
||||||
@@ -242,8 +243,8 @@ class Api {
|
|||||||
};
|
};
|
||||||
|
|
||||||
final payload = <dynamic, dynamic>{
|
final payload = <dynamic, dynamic>{
|
||||||
'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000',
|
'mt_instanceid': await DeviceIdentity.instanceId(),
|
||||||
'clientSessionId': 42,
|
'clientSessionId': DeviceIdentity.clientSessionId,
|
||||||
'deviceId': deviceId,
|
'deviceId': deviceId,
|
||||||
'userAgent': _userAgent,
|
'userAgent': _userAgent,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
abstract class DeviceIdentity {
|
||||||
|
static const String _instanceIdKey = 'mt_instance_id';
|
||||||
|
static const String _deviceIdKey = 'device_id_local';
|
||||||
|
|
||||||
|
static final Random _rng = Random.secure();
|
||||||
|
static int? _clientSessionId;
|
||||||
|
|
||||||
|
static int get clientSessionId =>
|
||||||
|
_clientSessionId ??= _rng.nextInt(0x7FFFFFFF) + 1;
|
||||||
|
|
||||||
|
static Future<String> instanceId() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final existing = prefs.getString(_instanceIdKey);
|
||||||
|
if (existing != null && existing.isNotEmpty) return existing;
|
||||||
|
final generated = _uuidV4();
|
||||||
|
await prefs.setString(_instanceIdKey, generated);
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> deviceId() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final existing = prefs.getString(_deviceIdKey);
|
||||||
|
if (existing != null && existing.isNotEmpty) return existing;
|
||||||
|
final generated = _hex(8);
|
||||||
|
await prefs.setString(_deviceIdKey, generated);
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _hex(int bytes) {
|
||||||
|
final sb = StringBuffer();
|
||||||
|
for (var i = 0; i < bytes; i++) {
|
||||||
|
sb.write(_rng.nextInt(256).toRadixString(16).padLeft(2, '0'));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _uuidV4() {
|
||||||
|
final b = List<int>.generate(16, (_) => _rng.nextInt(256));
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40;
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80;
|
||||||
|
String h(int i) => b[i].toRadixString(16).padLeft(2, '0');
|
||||||
|
return '${h(0)}${h(1)}${h(2)}${h(3)}-${h(4)}${h(5)}-${h(6)}${h(7)}-'
|
||||||
|
'${h(8)}${h(9)}-${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'dart:typed_data';
|
|||||||
import '../config/proxy_config.dart';
|
import '../config/proxy_config.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
import 'proxy_connector.dart';
|
import 'proxy_connector.dart';
|
||||||
|
import 'tls_config.dart';
|
||||||
import 'vpn_bypass.dart';
|
import 'vpn_bypass.dart';
|
||||||
|
|
||||||
enum SocketState { disconnected, connecting, connected }
|
enum SocketState { disconnected, connecting, connected }
|
||||||
@@ -106,11 +107,18 @@ class Connection {
|
|||||||
? await RawSocket.connect(host, port)
|
? await RawSocket.connect(host, port)
|
||||||
: await RawSocket.connect(host, port, timeout: timeout);
|
: await RawSocket.connect(host, port, timeout: timeout);
|
||||||
}
|
}
|
||||||
return RawSecureSocket.secure(
|
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
||||||
rawSocket,
|
if (allowInsecure) {
|
||||||
host: host,
|
logger.w(
|
||||||
onBadCertificate: (_) => true,
|
'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM',
|
||||||
);
|
);
|
||||||
|
return RawSecureSocket.secure(
|
||||||
|
rawSocket,
|
||||||
|
host: host,
|
||||||
|
onBadCertificate: (_) => true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RawSecureSocket.secure(rawSocket, host: host);
|
||||||
}
|
}
|
||||||
|
|
||||||
void write(Uint8List data) {
|
void write(Uint8List data) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import '../protocol/packet.dart';
|
import '../protocol/packet.dart';
|
||||||
import '../protocol/opcode_map.dart';
|
import '../protocol/opcode_map.dart';
|
||||||
|
import '../utils/log_redact.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
|
|
||||||
typedef PacketHandler = void Function(Packet packet);
|
typedef PacketHandler = void Function(Packet packet);
|
||||||
@@ -53,12 +54,8 @@ class PacketDispatcher {
|
|||||||
if (packet.cmd == CmdType.ok ||
|
if (packet.cmd == CmdType.ok ||
|
||||||
packet.cmd == CmdType.error ||
|
packet.cmd == CmdType.error ||
|
||||||
packet.cmd == CmdType.notFound) {
|
packet.cmd == CmdType.notFound) {
|
||||||
final payloadStr = packet.payload.toString();
|
|
||||||
final displayPayload = packet.opcode == Opcode.login && payloadStr.length > 50
|
|
||||||
? '${payloadStr.substring(0, 50)}...'
|
|
||||||
: payloadStr;
|
|
||||||
logger.i(
|
logger.i(
|
||||||
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: $displayPayload}',
|
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${redactForLog(packet.payload)}}',
|
||||||
);
|
);
|
||||||
|
|
||||||
final completer = _pendingRequests.remove(packet.seq);
|
final completer = _pendingRequests.remove(packet.seq);
|
||||||
@@ -84,7 +81,7 @@ class PacketDispatcher {
|
|||||||
}
|
}
|
||||||
} else if (packet.isPush) {
|
} else if (packet.isPush) {
|
||||||
logger.i(
|
logger.i(
|
||||||
'<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}',
|
'<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${redactForLog(packet.payload)}}',
|
||||||
);
|
);
|
||||||
_pushHandlers[packet.opcode]?.call(packet);
|
_pushHandlers[packet.opcode]?.call(packet);
|
||||||
_pushController.add(packet);
|
_pushController.add(packet);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '../protocol/packet.dart';
|
import '../protocol/packet.dart';
|
||||||
|
import '../utils/log_redact.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
import 'connection.dart';
|
import 'connection.dart';
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ class PacketSender {
|
|||||||
final data = packPacket(opcode, payload, seq: seq);
|
final data = packPacket(opcode, payload, seq: seq);
|
||||||
connection.write(data);
|
connection.write(data);
|
||||||
logger.i(
|
logger.i(
|
||||||
'=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: $payload}',
|
'=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${redactForLog(payload)}}',
|
||||||
);
|
);
|
||||||
return seq;
|
return seq;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
abstract class TlsConfig {
|
||||||
|
static const String prefKey = 'dev_tls_insecure';
|
||||||
|
|
||||||
|
static Future<bool> isInsecureAllowed() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(prefKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setInsecureAllowed(bool value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(prefKey, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const _redacted = '***';
|
||||||
|
|
||||||
|
const _sensitiveSubstrings = ['password', 'token', 'phone', 'secret'];
|
||||||
|
|
||||||
|
const _sensitiveExact = {
|
||||||
|
'code',
|
||||||
|
'verifycode',
|
||||||
|
'smscode',
|
||||||
|
'otp',
|
||||||
|
'hint',
|
||||||
|
'pin',
|
||||||
|
'qrlink',
|
||||||
|
'text',
|
||||||
|
'msisdn',
|
||||||
|
};
|
||||||
|
|
||||||
|
bool _isSensitiveKey(Object? key) {
|
||||||
|
if (key is! String) return false;
|
||||||
|
final k = key.toLowerCase();
|
||||||
|
if (_sensitiveExact.contains(k)) return true;
|
||||||
|
for (final s in _sensitiveSubstrings) {
|
||||||
|
if (k.contains(s)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic redactForLog(dynamic value) {
|
||||||
|
if (value is Map) {
|
||||||
|
final out = {};
|
||||||
|
value.forEach((k, v) {
|
||||||
|
out[k] = _isSensitiveKey(k) ? _redacted : redactForLog(v);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (value is List) {
|
||||||
|
return value.map(redactForLog).toList();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -226,6 +226,74 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: appState == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: appState.tlsInsecureEnabled,
|
||||||
|
builder: (context, insecureOn, _) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 17,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.gpp_bad,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Отключить проверку TLS',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Принимать любой сертификат сервера. '
|
||||||
|
'Только для отладки через MitM-прокси — '
|
||||||
|
'соединение становится уязвимым к '
|
||||||
|
'перехвату трафика',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: insecureOn,
|
||||||
|
onChanged: (v) {
|
||||||
|
appState.setTlsInsecureEnabled(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'backend/modules/contacts.dart';
|
|||||||
import 'backend/modules/messages.dart';
|
import 'backend/modules/messages.dart';
|
||||||
import 'core/push/push_service.dart';
|
import 'core/push/push_service.dart';
|
||||||
import 'core/storage/app_database.dart';
|
import 'core/storage/app_database.dart';
|
||||||
|
import 'core/transport/tls_config.dart';
|
||||||
import 'core/transport/vpn_bypass.dart';
|
import 'core/transport/vpn_bypass.dart';
|
||||||
import 'core/storage/token_storage.dart';
|
import 'core/storage/token_storage.dart';
|
||||||
import 'core/utils/haptics.dart';
|
import 'core/utils/haptics.dart';
|
||||||
@@ -63,6 +64,7 @@ void main() async {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
||||||
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
|
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
|
||||||
|
final initialTlsInsecure = prefs.getBool(TlsConfig.prefKey) ?? false;
|
||||||
final initialFontId =
|
final initialFontId =
|
||||||
prefs.getString(AppFonts.prefKey) ?? AppFonts.fallback.id;
|
prefs.getString(AppFonts.prefKey) ?? AppFonts.fallback.id;
|
||||||
final initialFontScale = AppFonts.clampScale(
|
final initialFontScale = AppFonts.clampScale(
|
||||||
@@ -76,6 +78,7 @@ void main() async {
|
|||||||
initialLocale: initialLocale,
|
initialLocale: initialLocale,
|
||||||
initialFpsOverlay: initialFpsOverlay,
|
initialFpsOverlay: initialFpsOverlay,
|
||||||
initialVpnBypass: initialVpnBypass,
|
initialVpnBypass: initialVpnBypass,
|
||||||
|
initialTlsInsecure: initialTlsInsecure,
|
||||||
initialFontId: initialFontId,
|
initialFontId: initialFontId,
|
||||||
initialFontScale: initialFontScale,
|
initialFontScale: initialFontScale,
|
||||||
initialAccentSeed: initialAccentSeed,
|
initialAccentSeed: initialAccentSeed,
|
||||||
@@ -89,6 +92,7 @@ class KometApp extends StatefulWidget {
|
|||||||
required this.initialLocale,
|
required this.initialLocale,
|
||||||
this.initialFpsOverlay = false,
|
this.initialFpsOverlay = false,
|
||||||
this.initialVpnBypass = false,
|
this.initialVpnBypass = false,
|
||||||
|
this.initialTlsInsecure = false,
|
||||||
required this.initialFontId,
|
required this.initialFontId,
|
||||||
required this.initialFontScale,
|
required this.initialFontScale,
|
||||||
this.initialAccentSeed,
|
this.initialAccentSeed,
|
||||||
@@ -97,6 +101,7 @@ class KometApp extends StatefulWidget {
|
|||||||
final Locale initialLocale;
|
final Locale initialLocale;
|
||||||
final bool initialFpsOverlay;
|
final bool initialFpsOverlay;
|
||||||
final bool initialVpnBypass;
|
final bool initialVpnBypass;
|
||||||
|
final bool initialTlsInsecure;
|
||||||
final String initialFontId;
|
final String initialFontId;
|
||||||
final double initialFontScale;
|
final double initialFontScale;
|
||||||
final Color? initialAccentSeed;
|
final Color? initialAccentSeed;
|
||||||
@@ -130,6 +135,9 @@ class KometAppState extends State<KometApp> {
|
|||||||
late final ValueNotifier<bool> vpnBypassEnabled = ValueNotifier(
|
late final ValueNotifier<bool> vpnBypassEnabled = ValueNotifier(
|
||||||
widget.initialVpnBypass,
|
widget.initialVpnBypass,
|
||||||
);
|
);
|
||||||
|
late final ValueNotifier<bool> tlsInsecureEnabled = ValueNotifier(
|
||||||
|
widget.initialTlsInsecure,
|
||||||
|
);
|
||||||
late final ValueNotifier<double> fontScale = ValueNotifier(
|
late final ValueNotifier<double> fontScale = ValueNotifier(
|
||||||
widget.initialFontScale,
|
widget.initialFontScale,
|
||||||
);
|
);
|
||||||
@@ -216,6 +224,7 @@ class KometAppState extends State<KometApp> {
|
|||||||
_profileUpdateController.close();
|
_profileUpdateController.close();
|
||||||
fpsOverlayEnabled.dispose();
|
fpsOverlayEnabled.dispose();
|
||||||
vpnBypassEnabled.dispose();
|
vpnBypassEnabled.dispose();
|
||||||
|
tlsInsecureEnabled.dispose();
|
||||||
fontScale.dispose();
|
fontScale.dispose();
|
||||||
accentSeed.dispose();
|
accentSeed.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -235,6 +244,12 @@ class KometAppState extends State<KometApp> {
|
|||||||
await prefs.setBool(VpnBypassService.prefKey, value);
|
await prefs.setBool(VpnBypassService.prefKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setTlsInsecureEnabled(bool value) async {
|
||||||
|
if (tlsInsecureEnabled.value == value) return;
|
||||||
|
tlsInsecureEnabled.value = value;
|
||||||
|
await TlsConfig.setInsecureAllowed(value);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> applyLocale(Locale locale) async {
|
Future<void> applyLocale(Locale locale) async {
|
||||||
if (!AppLocalizations.supportedLocales.any(
|
if (!AppLocalizations.supportedLocales.any(
|
||||||
(l) => l.languageCode == locale.languageCode,
|
(l) => l.languageCode == locale.languageCode,
|
||||||
|
|||||||
Reference in New Issue
Block a user