Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ name: build

on:
push:
branches: [main]
tags:
- 'v*'
workflow_dispatch:
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
IS_STABLE: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-') }}

jobs:
build:
Expand Down Expand Up @@ -49,7 +51,7 @@ jobs:
submodules: recursive

- name: Setup Android Signing
if: startsWith(matrix.platform,'android')
if: startsWith(matrix.platform,'android') && secrets.KEYSTORE != ''
run: |
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
echo "${{ secrets.SERVICE_JSON }}" | base64 --decode > android/app/google-services.json
Expand Down
3 changes: 3 additions & 0 deletions arb/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
"profileNameNullValidationDesc": "Please input the profile name",
"profileUrlNullValidationDesc": "Please input the profile URL",
"profileUrlInvalidValidationDesc": "Please input a valid profile URL",
"subscriptionLoginPassword": "Website login password",
"subscriptionLoginPasswordHint": "For encrypted subscriptions only",
"subscriptionPasswordWrongTip": "Incorrect password, please try again",
"autoUpdate": "Auto update",
"autoUpdateInterval": "Auto update interval (minutes)",
"profileAutoUpdateIntervalNullValidationDesc": "Please enter the auto update interval time",
Expand Down
3 changes: 3 additions & 0 deletions arb/intl_ja.arb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
"profileNameNullValidationDesc": "プロファイル名を入力してください",
"profileUrlNullValidationDesc": "プロファイルURLを入力してください",
"profileUrlInvalidValidationDesc": "有効なプロファイルURLを入力してください",
"subscriptionLoginPassword": "ウェブサイトログインパスワード",
"subscriptionLoginPasswordHint": "暗号化された購読のみ",
"subscriptionPasswordWrongTip": "パスワードが正しくありません。もう一度お試しください",
"autoUpdate": "自動更新",
"autoUpdateInterval": "自動更新間隔(分)",
"profileAutoUpdateIntervalNullValidationDesc": "自動更新間隔を入力してください",
Expand Down
3 changes: 3 additions & 0 deletions arb/intl_ru.arb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
"profileNameNullValidationDesc": "Пожалуйста, введите имя профиля",
"profileUrlNullValidationDesc": "Пожалуйста, введите URL профиля",
"profileUrlInvalidValidationDesc": "Пожалуйста, введите действительный URL профиля",
"subscriptionLoginPassword": "Пароль входа на сайт",
"subscriptionLoginPasswordHint": "Только для зашифрованных подписок",
"subscriptionPasswordWrongTip": "Неверный пароль, попробуйте снова",
"autoUpdate": "Автообновление",
"autoUpdateInterval": "Интервал автообновления (минуты)",
"profileAutoUpdateIntervalNullValidationDesc": "Пожалуйста, введите интервал времени для автообновления",
Expand Down
3 changes: 3 additions & 0 deletions arb/intl_zh_CN.arb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
"profileNameNullValidationDesc": "请输入配置名称",
"profileUrlNullValidationDesc": "请输入配置URL",
"profileUrlInvalidValidationDesc": "请输入有效配置URL",
"subscriptionLoginPassword": "网站登录密码",
"subscriptionLoginPasswordHint": "仅加密订阅需要",
"subscriptionPasswordWrongTip": "密码错误,请重试",
"autoUpdate": "自动更新",
"autoUpdateInterval": "自动更新间隔(分钟)",
"profileAutoUpdateIntervalNullValidationDesc": "请输入自动更新间隔时间",
Expand Down
2 changes: 2 additions & 0 deletions lib/common/common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export 'request.dart';
export 'scroll.dart';
export 'snowflake.dart';
export 'string.dart';
export 'subscription_decrypt.dart';
export 'subscription_exception.dart';
export 'system.dart';
export 'task.dart';
export 'text.dart';
Expand Down
60 changes: 60 additions & 0 deletions lib/common/subscription_decrypt.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:crypto/crypto.dart';
import 'package:encrypt/encrypt.dart';
import 'package:fl_clash/common/common.dart';

/// HTTP response header for subscription encryption (same as v2rayN)
const String subscriptionEncryptionHeader = 'subscription-encryption';
const String aesEncryptionValue = 'true';

/// Check if response headers indicate AES-encrypted subscription
bool isSubscriptionEncrypted(String? headerValue) {
if (headerValue == null || headerValue.isEmpty) return false;
return headerValue.trim().toLowerCase() == aesEncryptionValue;
}

/// Try to decrypt subscription content (AES-128-CBC, same as v2rayN)
/// Key: MD5(password) as 32-char hex -> 16 bytes
/// IV: first 16 bytes of base64 decoded data
/// Cipher: bytes 16.. of base64 decoded data
Uint8List? tryDecryptSubscription(String password, String base64Data) {
if (base64Data.isEmpty || password.isEmpty) return null;

final passHash = md5.convert(utf8.encode(password)).toString();
if (passHash.length != 32) return null;

Uint8List keyBytes;
try {
keyBytes = Uint8List.fromList(
List.generate(16, (i) => int.parse(passHash.substring(i * 2, i * 2 + 2), radix: 16)),
);
} catch (_) {
return null;
}

Uint8List raw;
try {
final normalized = base64Data.trim().replaceAll('\n', '').replaceAll('\r', '');
raw = base64Decode(normalized);
} catch (_) {
return null;
}

if (raw.length <= 16) return null;

final iv = IV(raw.sublist(0, 16));
final cipher = Encrypted(Uint8List.fromList(raw.sublist(16)));
final key = Key(keyBytes);

try {
final encrypter = Encrypter(AES(key, mode: AESMode.cbc, padding: 'PKCS7'));
final decrypted = encrypter.decrypt(cipher, iv: iv);
final bytes = Uint8List.fromList(utf8.encode(decrypted));
return bytes.isNotEmpty ? bytes : null;
} catch (e) {
commonPrint.log('Subscription decrypt error: $e', logLevel: LogLevel.warning);
return null;
}
}
11 changes: 11 additions & 0 deletions lib/common/subscription_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// Thrown when subscription content is encrypted but password is missing or wrong
class SubscriptionEncryptedException implements Exception {
const SubscriptionEncryptedException({this.passwordWrong = false});

/// True when password was provided but decryption failed (wrong password)
final bool passwordWrong;

@override
String toString() =>
'SubscriptionEncryptedException(passwordWrong: $passwordWrong)';
}
83 changes: 67 additions & 16 deletions lib/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -319,18 +319,44 @@ extension ProfilesControllerExt on AppController {
Profile profile, {
bool showLoading = false,
}) async {
try {
if (showLoading) {
_ref.read(isUpdatingProvider(profile.updatingKey).notifier).value =
true;
}
final newProfile = await profile.update();
_ref.read(profilesProvider.notifier).put(newProfile);
if (profile.id == _ref.read(currentProfileIdProvider)) {
applyProfileDebounce(silence: true);
var currentProfile = profile;
while (true) {
try {
if (showLoading) {
_ref.read(isUpdatingProvider(profile.updatingKey).notifier).value =
true;
}
final newProfile = await currentProfile.update();
_ref.read(profilesProvider.notifier).put(newProfile);
if (profile.id == _ref.read(currentProfileIdProvider)) {
applyProfileDebounce(silence: true);
}
return;
} on SubscriptionEncryptedException catch (e) {
final password = await globalState.showCommonDialog<String>(
child: InputDialog(
autovalidateMode: AutovalidateMode.onUnfocus,
title: appLocalizations.subscriptionLoginPassword,
labelText: appLocalizations.subscriptionLoginPassword,
value: currentProfile.loginPassword ?? '',
obscureText: true,
validator: (value) {
if (e.passwordWrong && (value == null || value.isEmpty)) {
return appLocalizations.subscriptionPasswordWrongTip;
}
return null;
},
),
);
if (password == null) return;
currentProfile = currentProfile.copyWith(loginPassword: password);
_ref.read(profilesProvider.notifier).put(currentProfile);
} finally {
if (showLoading) {
_ref.read(isUpdatingProvider(profile.updatingKey).notifier).value =
false;
}
}
} finally {
_ref.read(isUpdatingProvider(profile.updatingKey).notifier).value = false;
}
}

Expand All @@ -339,11 +365,35 @@ extension ProfilesControllerExt on AppController {
globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst);
}
toProfiles();
final profile = await loadingRun(tag: LoadingTag.profiles, () async {
return await Profile.normal(url: url).update();
}, title: appLocalizations.addProfile);
if (profile != null) {
putProfile(profile);
var loginPassword = '';
while (true) {
try {
final profile = await loadingRun(tag: LoadingTag.profiles, () async {
return await Profile.normal(url: url, loginPassword: loginPassword.isEmpty ? null : loginPassword).update();
}, title: appLocalizations.addProfile);
if (profile != null) {
putProfile(profile);
}
return;
} on SubscriptionEncryptedException catch (e) {
final password = await globalState.showCommonDialog<String>(
child: InputDialog(
autovalidateMode: AutovalidateMode.onUnfocus,
title: appLocalizations.subscriptionLoginPassword,
labelText: appLocalizations.subscriptionLoginPassword,
value: loginPassword,
obscureText: true,
validator: (value) {
if (e.passwordWrong && (value == null || value.isEmpty)) {
return appLocalizations.subscriptionPasswordWrongTip;
}
return null;
},
),
);
if (password == null) return;
loginPassword = password;
}
}
}

Expand Down Expand Up @@ -1181,6 +1231,7 @@ extension CommonControllerExt on AppController {
final res = await futureFunction();
return res;
} catch (e, s) {
if (e is SubscriptionEncryptedException) rethrow;
commonPrint.log('$title ===> $e, $s', logLevel: LogLevel.warning);
if (silence) {
globalState.showNotifier(e.toString());
Expand Down
11 changes: 10 additions & 1 deletion lib/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,16 @@ class Database extends _$Database {
Database([QueryExecutor? executor]) : super(executor ?? _openConnection());

@override
int get schemaVersion => 1;
int get schemaVersion => 2;

@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (migrator, from, to) async {
if (from < 2) {
await migrator.addColumn(profiles, profiles.loginPassword);
}
},
);

static LazyDatabase _openConnection() {
return LazyDatabase(() async {
Expand Down
Loading