diff --git a/lib/page/login/jc_captcha.dart b/lib/page/login/jc_captcha.dart index 6f8cecb5..2efe1cad 100644 --- a/lib/page/login/jc_captcha.dart +++ b/lib/page/login/jc_captcha.dart @@ -5,15 +5,16 @@ // https://juejin.cn/post/7284608063914622995 import 'dart:convert'; +import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; + import 'package:dio/dio.dart'; +import 'package:encrypter_plus/encrypter_plus.dart' as encrypt; import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; -import 'package:image/image.dart' as img; import 'package:styled_widget/styled_widget.dart'; import 'package:watermeter/repository/logger.dart'; -import 'package:watermeter/repository/network_session.dart'; class Lazy { final T Function() _initializer; @@ -25,11 +26,11 @@ class Lazy { T get value => _value ??= _initializer(); } -/// Finger movement track point +/// 轨迹点模型 class TrackPoint { - final int a; // x pos - final int b; // y pos - final int c; // milliseconds + final int a; // x 轴位移 + final int b; // y 轴位移 + final int c; // 时间戳 (毫秒) TrackPoint(this.a, this.b, this.c); @@ -37,24 +38,138 @@ class TrackPoint { } class SliderCaptchaClientProvider { + static const int _blockSize = 16; + static const int _captchaKeySize = 16; + static const int _keySize = 16; + static const String _aesChars = + "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; + static final Random _random = Random.secure(); + final String cookie; Dio dio = Dio()..interceptors.add(logDioAdapter); + /// 生成指定长度的随机字符串 + static String randomString(int n) { + final random = Random(); + return List.generate( + n, + (index) => _aesChars[random.nextInt(_aesChars.length)], + ).join(); + } + + /// 加密逻辑 + static String encryptData(String plainText, Uint8List keyBytes) { + final ivStr = randomString(_blockSize); + final nonce = randomString(_blockSize * 4); + final plain = nonce + plainText; + + final key = encrypt.Key(keyBytes); + final iv = encrypt.IV.fromUtf8(ivStr); + + final encrypter = encrypt.Encrypter( + encrypt.AES(key, mode: encrypt.AESMode.cbc), + ); + + // encrypt.AES 默认使用 PKCS7 填充,等同于 Python 的 pad(..., 16) + final encrypted = encrypter.encrypt(plain, iv: iv); + + return encrypted.base64; + } + + /// 解密逻辑 + static String decryptData(String cipherText, Uint8List keyBytes) { + final Uint8List fullCipher = base64.decode(cipherText); + + if (fullCipher.length < _blockSize * 4) { + throw Exception("Cipher text is too short to contain nonce."); + } + + // 根据 Python 逻辑:IV 是密文的第 48-64 字节 (Block 4) + // 实际密文从第 64 字节开始 + final ivBytes = fullCipher.sublist(_blockSize * 3, _blockSize * 4); + final encryptedPayload = fullCipher.sublist(_blockSize * 4); + + final key = encrypt.Key(keyBytes); + final iv = encrypt.IV(ivBytes); + + final encrypter = encrypt.Encrypter( + encrypt.AES(key, mode: encrypt.AESMode.cbc), + ); + + // 解密并自动去除 PKCS7 填充 + final decrypted = encrypter.decrypt( + encrypt.Encrypted(encryptedPayload), + iv: iv, + ); + + return decrypted; + } + + /// 从图片字节数组末尾提取 AES Key + static Uint8List extractAesKeyFromImage(Uint8List imageBytes) { + if (imageBytes.length < _keySize) { + throw Exception("Image is too short to contain AES key."); + } + return imageBytes.sublist(imageBytes.length - _keySize); + } + + /// 优化后的轨迹生成函数 + List generateTracks(int targetX) { + List tracks = []; + Random random = Random(); + + int currentX = 0; + int currentY = 0; + + // 1. 起始点 [cite: 89, 90] + tracks.add(TrackPoint(0, 0, 0)); + + // 调整后的参数:更大的步长,更紧凑的时间 + // 参考你提供的样本:位移 32 像素仅用了 9 个点 + while (currentX < targetX) { + int remaining = targetX - currentX; + + // 增大步长随机区间 (5-9 像素),这样点数会明显减少 + int stepX = remaining > 20 + ? random.nextInt(5) + 5 + : random.nextInt(3) + 1; + + currentX += stepX; + if (currentX > targetX) currentX = targetX; + + // 减小垂直抖动频率,使其看起来更平滑 [cite: 120] + if (random.nextDouble() > 0.7) { + currentY += random.nextBool() ? 1 : -1; + } + + // 将时间间隔 c 锁定在 20-25ms 之间,匹配你提供的样本 + int stepTime = 20 + random.nextInt(6); + + tracks.add(TrackPoint(currentX, currentY, stepTime)); + + if (currentX == targetX) break; + } + + // 2. 结束点:最后的停留点 [cite: 106, 107] + tracks.add(TrackPoint(targetX, currentY, 20 + random.nextInt(10))); + + return tracks; + } + SliderCaptchaClientProvider({required this.cookie}); - final double _puzzleWidth = 280; - final double _puzzleHeight = 155; - final double _pieceWidth = 44; - final double _pieceHeight = 155; - Uint8List? _puzzleData; - Uint8List? _pieceData; - Lazy? _puzzleImage; - Lazy? _pieceImage; - Uint8List? _aesKey; - - // fetch and update captcha data + Uint8List? puzzleData; + Uint8List? pieceData; + Lazy? puzzleImage; + Lazy? pieceImage; + Uint8List? extractedKey; + + final double puzzleWidth = 280; + final double puzzleHeight = 155; + final double pieceWidth = 44; + final double pieceHeight = 155; + Future updatePuzzle() async { - // fetch captcha data log.info("Fetching slider captcha..."); var rsp = await dio.get( "https://ids.xidian.edu.cn/authserver/common/openSliderCaptcha.htl", @@ -62,58 +177,37 @@ class SliderCaptchaClientProvider { options: Options(headers: {"Cookie": cookie}), ); log.info("Captcha fetched, decoding images."); - // decode base64 and extract aes key + String puzzleBase64 = rsp.data["bigImage"]; String pieceBase64 = rsp.data["smallImage"]; - _puzzleData = const Base64Decoder().convert(puzzleBase64); - _pieceData = const Base64Decoder().convert(pieceBase64); - _aesKey = _pieceData!.sublist(_pieceData!.length - 16); // key = last 16B - // update images - _puzzleImage = Lazy( + // double coordinatesY = double.parse(rsp.data["tagWidth"].toString()); + + puzzleData = const Base64Decoder().convert(puzzleBase64); + pieceData = const Base64Decoder().convert(pieceBase64); + + extractedKey = extractAesKeyFromImage(pieceData!); + + puzzleImage = Lazy( () => Image.memory( - _puzzleData!, - width: _puzzleWidth, - height: _puzzleHeight, + puzzleData!, + width: puzzleWidth, + height: puzzleHeight, fit: BoxFit.fitWidth, ), ); - _pieceImage = Lazy( + pieceImage = Lazy( () => Image.memory( - _pieceData!, - width: _pieceWidth, - height: _pieceHeight, + pieceData!, + width: pieceWidth, + height: pieceHeight, fit: BoxFit.fitWidth, ), ); } - // solve slider captcha Future solve(BuildContext? context) async { - log.info("Solving slider captcha automatically"); - // multiple tries - for (int i = 0; i < 6; ++i) { - // refresh captcha - await updatePuzzle(); - final offset = solveOffset(_puzzleData!, _pieceData!); - if (offset == null) throw CaptchaSolveFailedException(); - final int baseMove = (offset * _puzzleWidth).round(); - // try neighboring moves - for (final delta in [1, -1, 2, -2, 3, -3, 4]) { - final move = baseMove + delta; - if (move < 0 || move > _puzzleWidth.toInt()) continue; - final tracks = generateTracks(move); - // sleep - await Future.delayed( - Duration(milliseconds: max(tracks.last.c - 100, 0)), - ); - // verify - try { - if (await verify(tracks)) return; - } catch (_) {} - } - } - // fallback to manual solving - log.info("Solving slider captcha manually"); + // 自动解码滑块偏移量已停用。这里始终进入手动滑块,提交用户真实拖动轨迹。 + log.info("Skipping auto-solve, entering manual slider."); if (context != null && context.mounted) { final verified = await Navigator.of(context).push( MaterialPageRoute(builder: (context) => CaptchaWidget(provider: this)), @@ -123,186 +217,62 @@ class SliderCaptchaClientProvider { throw CaptchaSolveFailedException(); } - // submit and verify captcha - Future verify(List tracks) async { - final payload = { - "canvasLength": _puzzleWidth.toInt(), - "moveLength": tracks.isNotEmpty ? tracks.last.a : 0, + Future verifyWithTracks(List tracks) async { + final moveLength = tracks.isNotEmpty ? tracks.last.a : 0; + final payload = jsonEncode({ + "canvasLength": puzzleWidth.toInt(), + "moveLength": moveLength, "tracks": tracks, - }; - final sign = aesEncrypt(jsonEncode(payload), _aesKey!); + }); + log.info( + "Verify captcha with ${tracks.length} track points " + "(moveLength=$moveLength).", + ); + final sign = _encryptPayload(payload); + dynamic result = await dio.post( "https://ids.xidian.edu.cn/authserver/common/verifySliderCaptcha.htl", data: "sign=${Uri.encodeQueryComponent(sign)}", + options: Options( + headers: { + HttpHeaders.acceptHeader: + "application/json, text/javascript, */*; q=0.01", + "Cookie": cookie, + HttpHeaders.contentTypeHeader: + "application/x-www-form-urlencoded;charset=UTF-8", + "Origin": "https://ids.xidian.edu.cn", + HttpHeaders.accessControlAllowOriginHeader: + "https://ids.xidian.edu.cn", + "X-Requested-With": "XMLHttpRequest", + }, + ), ); - log.info( - "Tried captcha moveLength:${payload["moveLength"]}, result:${result.data}", - ); + log.info("Verify response: ${result.data}"); return result.data["errorCode"] == 1; } - /// - /// Slider CAPTCHA offset solver - /// - - // match offset with ncc. - static double? solveOffset( - Uint8List puzzleData, - Uint8List pieceData, { - int border = 24, - }) { - img.Image? puzzle = img.decodeImage(puzzleData); - if (puzzle == null) return null; - img.Image? piece = img.decodeImage(pieceData); - if (piece == null) return null; - // find bbox for the piece image - var bbox = _findAlphaBoundingBox(piece); - var xL = bbox[0] + border, - yT = bbox[1] + border, - xR = bbox[2] - border, - yB = bbox[3] - border; - - var widthW = xR - xL + 1, heightW = yB - yT + 1, lenW = widthW * heightW; - var widthG = puzzle.width - piece.width + widthW; - // normalize - var meanT = _calculateSum(piece, xL, yT, widthW, heightW) / widthW / heightW; - var templateN = _normalizeImage(piece, xL, yT, widthW, heightW, meanT); - var colsW = [ - for (var x = xL; x < widthG; ++x) - _calculateSum(puzzle, x, yT, 1, heightW), - ]; - // init window - var colsWL = colsW.iterator, colsWR = colsW.iterator; - double sumW = 0; - for (var i = 0; i < widthW; ++i) { - colsWR.moveNext(); - sumW += colsWR.current; + String _encryptPayload(String payload) { + if (pieceData == null || pieceData!.length < _captchaKeySize) { + throw StateError("Captcha image is too short to contain AES key."); } - // slide window and ncc - double nccMax = _calculateNCC( - puzzle, - 0, - yT, - widthW, - heightW, - templateN, - sumW / lenW, - ); - int xMax = 0; - for (var x = 1; x < widthG - widthW; ++x) { - colsWL.moveNext(); - colsWR.moveNext(); - sumW = sumW - colsWL.current + colsWR.current; - var ncc = _calculateNCC( - puzzle, - x, - yT, - widthW, - heightW, - templateN, - sumW / lenW, - ); - if (ncc > nccMax) { - nccMax = ncc; - xMax = x; - } - } - // return progress - return xMax / puzzle.width; - } - - // find bbox - static List _findAlphaBoundingBox(img.Image image) { - var xL = image.width, yT = image.height, xR = 0, yB = 0; - for (var y = 0; y < image.height; y++) - for (var x = 0; x < image.width; x++) { - if (image.getPixel(x, y).a != 255) continue; - if (x < xL) xL = x; - if (y < yT) yT = y; - if (x > xR) xR = x; - if (y > yB) yB = y; - } - return [xL, yT, xR, yB]; - } - // calculate sum of area in an image - static double _calculateSum( - img.Image image, - int x, - int y, - int width, - int height, - ) { - double sum = 0; - for (var yy = y; yy < y + height; yy++) - for (var xx = x; xx < x + width; xx++) - sum += image.getPixel(xx, yy).luminance; - return sum; - } + final keyBytes = pieceData!.sublist(pieceData!.length - _captchaKeySize); + final key = encrypt.Key(Uint8List.fromList(keyBytes)); + final iv = encrypt.IV.fromUtf8(_randomString(_blockSize)); + final nonce = _randomString(_blockSize * 4); + final aes = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.cbc)); - // normalize area in an image - static List _normalizeImage( - img.Image image, - int x, - int y, - int width, - int height, - double mean, - ) { - return [ - for (var yy = 0; yy < height; yy++) - for (var xx = 0; xx < width; xx++) - image.getPixel(xx + x, yy + y).luminance - mean, - ]; - } - - // calculate ncc of area in an image with a template - static double _calculateNCC( - img.Image window, - int x, - int y, - int width, - int height, - List template, - double meanW, - ) { - double sumWt = 0, sumWw = 0.000001; - var iT = template.iterator; - for (var yy = y; yy < y + height; yy++) - for (var xx = x; xx < x + width; xx++) { - iT.moveNext(); - var w = window.getPixel(xx, yy).luminance - meanW; - sumWt += w * iT.current; - sumWw += w * w; - } - return sumWt / sumWw; + final plain = "$nonce$payload"; + return aes.encrypt(plain, iv: iv).base64; } - /// - /// Finger move track generation - /// - - static final _rng = Random(); - static final _genTracksNorm = 1.0 / (1.0 + exp(-7.0 * (1.0 - 0.42))); - - // generate track along an skewed sigmoid curve - List generateTracks(int offs) { - final tracks = [TrackPoint(0, 0, 0)]; - final int n = _rng.nextInt(5) + 10; - int b = 0; - for (int i = 0; i < n; i++) { - // horizontal - final double z = - (1.0 / (1.0 + exp(-7.0 * ((i / n) - 0.42)))) / _genTracksNorm; - final int a = min(offs - 1, max(tracks.last.a + 1, (offs * z).round())); - // vertical - final double r = _rng.nextDouble(); - b = ((r < 0.65) ? (b - 1) : ((r < 0.80) ? (b + 1) : (b))); - b = max(-10, min(10, b)); - tracks.add(TrackPoint(a, b, _rng.nextInt(701) + 900)); - } - tracks.add(TrackPoint(offs, b, _rng.nextInt(701) + 900)); - return tracks; + static String _randomString(int length) { + return String.fromCharCodes( + List.generate( + length, + (_) => _aesChars.codeUnitAt(_random.nextInt(_aesChars.length)), + ), + ); } } @@ -458,7 +428,7 @@ class _CaptchaWidgetState extends State { }); try { - final verified = await widget.provider.verify(_tracks); + final verified = await widget.provider.verifyWithTracks(_tracks); if (!mounted) return; if (verified) { Navigator.of(context).pop(true); @@ -549,8 +519,8 @@ class _CaptchaWidgetState extends State { } Widget _buildCaptcha(SliderCaptchaClientProvider provider) { - final pw = provider._puzzleWidth; - final ph = provider._puzzleHeight; + final pw = provider.puzzleWidth; + final ph = provider.puzzleHeight; return Column( children: [ SizedBox( @@ -559,10 +529,10 @@ class _CaptchaWidgetState extends State { child: Stack( alignment: Alignment.center, children: [ - provider._puzzleImage!.value, + provider.puzzleImage!.value, Positioned( left: _sliderLeftPx, - child: provider._pieceImage!.value, + child: provider.pieceImage!.value, ), ], ), diff --git a/lib/repository/network_session.dart b/lib/repository/network_session.dart index 18a9372d..51387af5 100644 --- a/lib/repository/network_session.dart +++ b/lib/repository/network_session.dart @@ -5,36 +5,16 @@ // General network class. import 'dart:io'; -import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; -import 'package:encrypter_plus/encrypter_plus.dart' as encrypt; +import 'package:flutter/widgets.dart'; import 'package:watermeter/model/session_state.dart'; import 'package:watermeter/repository/logger.dart'; late Directory supportPath; -/// AES-CBC encryption with Pkcs7 padding -/// used for IDS CAPTCHA payload & password encryption -final _rng = Random(); -const int _blockSize = 16; -const String _aesChars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; - -String aesEncrypt(String text, Uint8List keyBytes) { - final randstr = [ - for (int i = 0; i < _blockSize * 5; i++) - _aesChars[_rng.nextInt(_aesChars.length)], - ].join(); - final plain = randstr.substring(0, 63) + text; // prepend 64B nonce - final key = encrypt.Key(keyBytes); - final iv = encrypt.IV.fromUtf8(randstr.substring(64, 79)); // 16B iv - return encrypt.Encrypter( - encrypt.AES(key, mode: encrypt.AESMode.cbc), - ).encrypt(plain, iv: iv).base64; -} - class NetworkSession { static SessionState _isInit = SessionState.none; diff --git a/lib/repository/xidian_ids/ids_session.dart b/lib/repository/xidian_ids/ids_session.dart index 03d1a372..d3dc7f89 100644 --- a/lib/repository/xidian_ids/ids_session.dart +++ b/lib/repository/xidian_ids/ids_session.dart @@ -9,6 +9,7 @@ import 'dart:io'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:html/parser.dart'; +import 'package:encrypter_plus/encrypter_plus.dart' as encrypt; import 'package:synchronized/synchronized.dart'; import 'package:watermeter/page/login/jc_captcha.dart'; import 'package:watermeter/repository/logger.dart'; @@ -59,6 +60,32 @@ class IDSSession extends NetworkSession { Dio get dioNoOfflineCheck => super.dio; + /// Get base64 encoded data. Which is aes encrypted [toEnc] encoded string using [key]. + /// Padding part is libxduauth's idea. + String aesEncrypt(String toEnc, String key) { + dynamic k = encrypt.Key.fromUtf8(key); + var crypt = encrypt.AES(k, mode: encrypt.AESMode.cbc, padding: null); + + /// Start padding + int blockSize = 16; + List dataToPad = []; + dataToPad.addAll( + utf8.encode( + "xidianscriptsxduxidianscriptsxduxidianscriptsxduxidianscriptsxdu$toEnc", + ), + ); + int paddingLength = blockSize - dataToPad.length % blockSize; + for (var i = 0; i < paddingLength; ++i) { + dataToPad.add(paddingLength); + } + String readyToEnc = utf8.decode(dataToPad); + + /// Start encrypt. + return encrypt.Encrypter( + crypt, + ).encrypt(readyToEnc, iv: encrypt.IV.fromUtf8('xidianscriptsxdu')).base64; + } + static const _header = [ // "username", // "password", @@ -200,7 +227,7 @@ class IDSSession extends NetworkSession { } Map head = { 'username': username, - 'password': aesEncrypt(password, utf8.encode(keys)), + 'password': aesEncrypt(password, keys), 'rememberMe': 'true', 'cllt': 'userNameLogin', 'dllt': 'generalLogin',