Files
Qlyra/lib/backend/modules/digital_id.dart
T
klockky 08d5cabae5 feat(spoof): пер-аккаунтный спуф с тумблером и прокидыванием в веб-аппы
- спуф теперь свой на каждый аккаунт, вмораживается при входе
- тумблер вкл/выкл в настройках (по умолчанию выключен)
- новый аккаунт добавляется со своим включённым спуфом (другое устройство)
- UA спуфа прокинут в Сферум, Цифровой ID (вебвью + HTTP)
- генерация выбирает Android или iOS; iOS отдаёт deviceType IOS
- экран спуфа показывает текущую личность, а не свежесгенерированную
2026-06-20 15:15:09 +00:00

377 lines
12 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:device_info_plus/device_info_plus.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/spoofing_service.dart';
import '../../core/storage/token_storage.dart';
import '../../models/digital_id.dart';
import 'webapp.dart';
class DigitalIdException implements Exception {
final String code;
final String message;
const DigitalIdException(this.code, this.message);
bool get isUnauthorized => code == 'UNAUTHORIZED';
bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK';
@override
String toString() => message;
}
class DigitalIdModule {
static const String _baseUrl = 'https://ext-api.max.ru';
static const String _deviceIdKey = 'digital_id_device_id';
static const String _tokenKey = 'digital_id_biometry_token';
final WebAppModule _webApp;
final HttpClient _http = HttpClient()
..connectionTimeout = const Duration(seconds: 20);
String? _webAppData;
String? _deviceId;
String? _realUserAgent;
DigitalIdModule(this._webApp);
Future<String> _ensureWebAppData({bool forceRefresh = false}) async {
if (!forceRefresh && _webAppData != null) return _webAppData!;
final launch = await _webApp.fetchDigitalId();
final data = _extractWebAppData(launch.url);
if (data == null) {
throw const DigitalIdException(
'NO_INIT_DATA',
'Не удалось получить данные авторизации Цифрового ID',
);
}
_webAppData = data;
return data;
}
String? _extractWebAppData(String url) {
final hashIndex = url.indexOf('#');
if (hashIndex < 0) return null;
final fragment = url.substring(hashIndex + 1);
final match = RegExp(r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)')
.firstMatch(fragment);
final raw = match?.group(1);
if (raw == null || raw.isEmpty) return null;
return Uri.decodeComponent(raw);
}
Future<String> deviceId() async {
if (_deviceId != null) return _deviceId!;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
final stored = await AppDatabase.getSyncValue(accountId, _deviceIdKey);
if (stored != null && stored.isNotEmpty) {
_deviceId = stored;
return stored;
}
}
final generated = await _generateDeviceId();
if (accountId != null) {
await AppDatabase.setSyncValue(accountId, _deviceIdKey, generated);
}
_deviceId = generated;
return generated;
}
Future<String> _generateDeviceId() async {
final rnd = Random.secure();
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
final hex =
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
return '${android.id}_$hex';
}
} catch (_) {}
return hex;
}
Future<String> _resolveUserAgent() async {
final spoofed = await SpoofingService.getWebViewUserAgent();
if (spoofed != null && spoofed.isNotEmpty) return spoofed;
return _realUserAgent ??= await _buildRealUserAgent();
}
Future<String> _buildRealUserAgent() async {
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
return 'Mozilla/5.0 (Linux; Android ${android.version.release}; '
'${android.model}) AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/124.0.0.0 Mobile Safari/537.36';
}
if (Platform.isIOS) {
final ios = await info.iosInfo;
final version = ios.systemVersion.replaceAll('.', '_');
return 'Mozilla/5.0 (iPhone; CPU iPhone OS $version like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 '
'Mobile/15E148 Safari/604.1';
}
} catch (_) {}
return 'Mozilla/5.0 (Linux; Android 14; K) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36';
}
Future<dynamic> _send(
String method,
String path, {
Object? body,
bool retry = true,
}) async {
final webAppData = await _ensureWebAppData();
final uri = Uri.parse('$_baseUrl$path');
final request = await _http.openUrl(method, uri);
request.headers.set('Authorization', '#WebAppData=$webAppData');
request.headers.set('Origin', 'https://digital-id.max.ru');
request.headers.set('Referer', 'https://digital-id.max.ru/');
request.headers.set('x-requested-with', 'ru.oneme.app');
request.headers.set('Accept', 'application/json');
request.headers.set('User-Agent', await _resolveUserAgent());
if (body != null) {
request.headers.contentType = ContentType.json;
request.add(utf8.encode(jsonEncode(body)));
}
final response = await request.close();
final text = await response.transform(utf8.decoder).join();
if (response.statusCode == 401 && retry) {
await _ensureWebAppData(forceRefresh: true);
return _send(method, path, body: body, retry: false);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw _errorFor(response.statusCode, text);
}
if (text.isEmpty) return null;
return jsonDecode(text);
}
DigitalIdException _errorFor(int statusCode, String body) {
String code = 'HTTP_$statusCode';
String message = 'Ошибка Цифрового ID ($statusCode)';
try {
final decoded = jsonDecode(body);
if (decoded is Map) {
final rawCode = decoded['code'] ?? decoded['error'] ?? decoded['status'];
if (rawCode is String && rawCode.isNotEmpty) code = rawCode;
final rawMessage = decoded['message'] ?? decoded['error_description'];
if (rawMessage is String && rawMessage.isNotEmpty) message = rawMessage;
}
} catch (_) {}
if (statusCode == 401) code = 'UNAUTHORIZED';
return DigitalIdException(code, message);
}
Map _unwrapData(dynamic decoded) {
if (decoded is Map && decoded['data'] is Map) {
return decoded['data'] as Map;
}
return decoded is Map ? decoded : const {};
}
Future<DigitalIdBiometryStatus> biometryStatus() async {
final decoded = await _send('GET', '/v2/digital-id/biometry-status');
return DigitalIdBiometryStatus.fromMap(_unwrapData(decoded));
}
Future<String> createBiometryToken({
required String deviceId,
String? photoHash,
}) async {
final decoded = await _send(
'POST',
'/v3/digital-id/create-biometry-token',
body: {
'device_id': deviceId,
'photo_hash': ?photoHash,
},
);
return _unwrapData(decoded)['token'] as String? ?? '';
}
Future<String> refreshUserDocs(String token) async {
final decoded =
await _send('POST', '/v3/digital-id/refresh-user-docs', body: {
'token': token,
});
return _unwrapData(decoded)['state'] as String? ?? '';
}
Future<DigitalIdUserDocs?> getUserDocs(String state) async {
final decoded = await _send('POST', '/v2/digital-id/get-user-docs', body: {
'state': state,
});
if (decoded is Map && decoded['status'] == 'done') {
final data = decoded['data'];
if (data is Map) return DigitalIdUserDocs.fromMap(data);
}
return null;
}
Future<DigitalIdEsiaLink> createEsiaLink() async {
final decoded = await _send('GET', '/v2/digital-id/create-esia-link');
return DigitalIdEsiaLink.fromMap(decoded is Map ? decoded : const {});
}
Future<DigitalIdVerification> verifyPhoto({
required String deviceId,
String? photoHash,
}) async {
final decoded = await _send('POST', '/digital-id-verify-photo', body: {
'device_id': deviceId,
'photo_hash': ?photoHash,
});
final status = decoded is Map ? decoded['status'] as String? : null;
return DigitalIdVerification.fromValue(status);
}
Future<bool> shadowMode(String deviceId) async {
try {
final decoded = await _send('POST', '/v3/digital-id/shadow-mode', body: {
'device_id': deviceId,
});
return _unwrapData(decoded)['shadow_mode'] == true;
} on DigitalIdException catch (e) {
if (e.code == 'HTTP_404') return false;
rethrow;
}
}
Future<void> updateGuHashes() async {
await _send('POST', '/v2/digital-id/update-gu-hashes', body: const {});
}
Future<DigitalIdUniversalQr> userQr(String token) async {
final decoded = await _send('POST', '/v3/digital-id/user-qr', body: {
'token': token,
});
return DigitalIdUniversalQr.fromMap(_unwrapData(decoded));
}
Future<DigitalIdQr> generateQr({
required String photo,
required String token,
required DigitalIdQrType qrType,
String? kidAct,
}) async {
final decoded = await _send('POST', '/v3/digital-id/generate-qr', body: {
'photo': photo,
'token': token,
'qr_type': qrType.code,
'kid_act': ?kidAct,
});
return DigitalIdQr.fromMap(_unwrapData(decoded));
}
Future<List<DigitalIdAcmsCard>> getCardsList({
String? passStatus,
String? inn,
}) async {
final query = <String, String>{
'pass_status': ?passStatus,
'inn': ?inn,
};
final suffix =
query.isEmpty ? '' : '?${Uri(queryParameters: query).query}';
final decoded =
await _send('GET', '/v2/digital-id/get-cards-list$suffix');
final cards = _unwrapData(decoded)['acms_cards'];
if (cards is! List) return const [];
return cards
.whereType<Map>()
.map(DigitalIdAcmsCard.fromMap)
.toList(growable: false);
}
Future<void> activateAcms({required String id, required String inn}) async {
await _send('POST', '/v2/digital-id/activate-acms', body: {
'id': id,
'inn': inn,
'pass_status': 'active',
});
}
Future<void> createLiteProfile(String deviceId) async {
await _send('POST', '/v2/digital-id/create-lite-profile', body: {
'device_id': deviceId,
});
}
Future<void> deleteProfile() async {
await _send('DELETE', '/v3/digital-id/delete-profile');
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await TokenStorage.deleteSecure('${_tokenKey}_$accountId');
await AppDatabase.setSyncValue(accountId, _tokenKey, '');
}
}
Future<String?> _storedToken() async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return null;
final secureKey = '${_tokenKey}_$accountId';
final secured = await TokenStorage.readSecure(secureKey);
if (secured != null && secured.isNotEmpty) return secured;
final legacy = await AppDatabase.getSyncValue(accountId, _tokenKey);
if (legacy != null && legacy.isNotEmpty) {
await TokenStorage.writeSecure(secureKey, legacy);
await AppDatabase.setSyncValue(accountId, _tokenKey, '');
return legacy;
}
return null;
}
Future<void> _saveToken(String token) async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await TokenStorage.writeSecure('${_tokenKey}_$accountId', token);
}
}
Future<String> ensureBiometryToken({String? photoHash}) async {
final existing = await _storedToken();
if (existing != null) return existing;
final id = await deviceId();
final token = await createBiometryToken(deviceId: id, photoHash: photoHash);
if (token.isNotEmpty) await _saveToken(token);
return token;
}
Future<DigitalIdUserDocs?> loadDocuments({
bool createIfMissing = false,
int attempts = 5,
}) async {
var token = await _storedToken();
if (token == null) {
if (!createIfMissing) return null;
final id = await deviceId();
token = await createBiometryToken(deviceId: id);
if (token.isEmpty) return null;
await _saveToken(token);
}
final state = await refreshUserDocs(token);
if (state.isEmpty) return null;
for (var attempt = 0; attempt < attempts; attempt++) {
final docs = await getUserDocs(state);
if (docs != null) return docs;
await Future.delayed(const Duration(seconds: 2));
}
return null;
}
void reset() {
_webAppData = null;
}
}