diff --git a/v2rayN/ServiceLib/Events/AppEvents.cs b/v2rayN/ServiceLib/Events/AppEvents.cs index 5824bfc0..71f6e78b 100644 --- a/v2rayN/ServiceLib/Events/AppEvents.cs +++ b/v2rayN/ServiceLib/Events/AppEvents.cs @@ -7,6 +7,7 @@ public static class AppEvents public static readonly EventChannel AddServerViaScanRequested = new(); public static readonly EventChannel AddServerViaClipboardRequested = new(); public static readonly EventChannel SubscriptionsUpdateRequested = new(); + public static readonly EventChannel SubscriptionDecryptFailedRequested = new(); public static readonly EventChannel ProfilesRefreshRequested = new(); public static readonly EventChannel SubscriptionsRefreshRequested = new(); diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index 37176608..25b0f89e 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -1669,6 +1669,7 @@ public static class ConfigHandler item.Remarks = subItem.Remarks; item.Url = subItem.Url; item.MoreUrl = subItem.MoreUrl; + item.LoginPassword = subItem.LoginPassword; item.Enabled = subItem.Enabled; item.AutoUpdateInterval = subItem.AutoUpdateInterval; item.UserAgent = subItem.UserAgent; diff --git a/v2rayN/ServiceLib/Handler/SubscriptionHandler.cs b/v2rayN/ServiceLib/Handler/SubscriptionHandler.cs index 4edd6c72..d212dd0c 100644 --- a/v2rayN/ServiceLib/Handler/SubscriptionHandler.cs +++ b/v2rayN/ServiceLib/Handler/SubscriptionHandler.cs @@ -1,8 +1,17 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; + namespace ServiceLib.Handler; public static class SubscriptionHandler { - public static async Task UpdateProcess(Config config, string subId, bool blProxy, Func updateFunc) + public static async Task UpdateProcess( + Config config, + string subId, + bool blProxy, + Func updateFunc, + Func? decryptFailedFunc = null) { await updateFunc?.Invoke(false, ResUI.MsgUpdateSubscriptionStart); var subItem = await AppManager.Instance.SubItems(); @@ -35,7 +44,13 @@ public static class SubscriptionHandler await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartGettingSubscriptions}"); // Get all subscription content (main subscription + additional subscriptions) - var result = await DownloadAllSubscriptions(config, item, blProxy, downloadHandle); + var (result, decryptFailed) = await DownloadAllSubscriptions(config, item, blProxy, downloadHandle); + if (decryptFailed) + { + await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgSubscriptionDecryptFailed}"); + await decryptFailedFunc?.Invoke(item); + continue; + } // Process download result if (await ProcessDownloadResult(config, item.Id, result, hashCode, updateFunc)) @@ -90,34 +105,69 @@ public static class SubscriptionHandler return downloadHandle; } - private static async Task DownloadSubscriptionContent(DownloadService downloadHandle, string url, bool blProxy, string userAgent) + private const string SubscriptionEncryptionHeader = "Subscription-Encryption"; + private const string AesEncryptionValue = "true"; + + private static bool IsAesEncrypted(HttpHeaders? headers) { - var result = await downloadHandle.TryDownloadString(url, blProxy, userAgent); - - // If download with proxy fails, try direct connection - if (blProxy && result.IsNullOrEmpty()) - { - result = await downloadHandle.TryDownloadString(url, false, userAgent); - } - - return result ?? string.Empty; + if (headers == null || !headers.TryGetValues(SubscriptionEncryptionHeader, out var values)) + return false; + return values?.Any(v => string.Equals(v.Trim(), AesEncryptionValue, StringComparison.OrdinalIgnoreCase)) == true; } - private static async Task DownloadAllSubscriptions(Config config, SubItem item, bool blProxy, DownloadService downloadHandle) + private static async Task<(string Content, HttpHeaders? Headers)> DownloadSubscriptionContentWithHeaders(DownloadService downloadHandle, string url, bool blProxy, string userAgent) { - // Download main subscription content - var result = await DownloadMainSubscription(config, item, blProxy, downloadHandle); + var (content, headers) = await downloadHandle.TryDownloadStringWithHeaders(url, blProxy, userAgent); + + // If download with proxy fails, try direct connection + if (blProxy && content.IsNullOrEmpty()) + { + (content, headers) = await downloadHandle.TryDownloadStringWithHeaders(url, false, userAgent); + } + + return (content ?? string.Empty, headers); + } + + private static async Task<(string Result, bool DecryptFailed)> DownloadAllSubscriptions(Config config, SubItem item, bool blProxy, DownloadService downloadHandle) + { + var decryptFailed = false; + // Download main subscription content (with headers for encryption detection) + var (result, mainHeaders) = await DownloadMainSubscriptionWithHeaders(config, item, blProxy, downloadHandle); + if (IsAesEncrypted(mainHeaders)) + { + if (item.LoginPassword.IsNullOrEmpty()) + { + return (string.Empty, true); + } + if (!TryDecryptSubscription(item, result, out var decrypted)) + { + return (string.Empty, true); + } + result = decrypted; + } + else + { + if (result.IsNotEmpty() && Utils.IsBase64String(result)) + { + result = Utils.Base64Decode(result); + } + } // Process additional subscription links (if any) if (item.ConvertTarget.IsNullOrEmpty() && item.MoreUrl.TrimEx().IsNotEmpty()) { - result = await DownloadAdditionalSubscriptions(item, result, blProxy, downloadHandle); + var additional = await DownloadAdditionalSubscriptions(item, result, blProxy, downloadHandle); + if (additional.DecryptFailed) + { + return (string.Empty, true); + } + result = additional.Result; } - return result; + return (result, decryptFailed); } - private static async Task DownloadMainSubscription(Config config, SubItem item, bool blProxy, DownloadService downloadHandle) + private static async Task<(string Content, HttpHeaders? Headers)> DownloadMainSubscriptionWithHeaders(Config config, SubItem item, bool blProxy, DownloadService downloadHandle) { // Prepare subscription URL and download directly var url = Utils.GetPunycode(item.Url.TrimEx()); @@ -142,20 +192,14 @@ public static class SubscriptionHandler } } - // Download and return result directly - return await DownloadSubscriptionContent(downloadHandle, url, blProxy, item.UserAgent); + // Download and return result with headers + return await DownloadSubscriptionContentWithHeaders(downloadHandle, url, blProxy, item.UserAgent); } - private static async Task DownloadAdditionalSubscriptions(SubItem item, string mainResult, bool blProxy, DownloadService downloadHandle) + private static async Task<(string Result, bool DecryptFailed)> DownloadAdditionalSubscriptions(SubItem item, string mainResult, bool blProxy, DownloadService downloadHandle) { var result = mainResult; - // If main subscription result is Base64 encoded, decode it first - if (result.IsNotEmpty() && Utils.IsBase64String(result)) - { - result = Utils.Base64Decode(result); - } - // Process additional URL list var lstUrl = item.MoreUrl.TrimEx().Split(",") ?? []; foreach (var it in lstUrl) @@ -166,23 +210,110 @@ public static class SubscriptionHandler continue; } - var additionalResult = await DownloadSubscriptionContent(downloadHandle, url2, blProxy, item.UserAgent); + var (additionalContent, additionalHeaders) = await DownloadSubscriptionContentWithHeaders(downloadHandle, url2, blProxy, item.UserAgent); - if (additionalResult.IsNotEmpty()) + if (additionalContent.IsNotEmpty()) { - // Process additional subscription results, add to main result - if (Utils.IsBase64String(additionalResult)) + // Check header for encryption; decrypt only when Subscription-Encryption present + if (IsAesEncrypted(additionalHeaders)) { - result += Environment.NewLine + Utils.Base64Decode(additionalResult); + if (item.LoginPassword.IsNullOrEmpty()) + { + return (string.Empty, true); + } + if (!TryDecryptSubscription(item, additionalContent, out var decrypted)) + { + return (string.Empty, true); + } + result += Environment.NewLine + decrypted; + } + else if (Utils.IsBase64String(additionalContent)) + { + result += Environment.NewLine + Utils.Base64Decode(additionalContent); } else { - result += Environment.NewLine + additionalResult; + result += Environment.NewLine + additionalContent; } } } - return result; + return (result, false); + } + + private static bool TryDecryptSubscription(SubItem item, string base64Data, out string decrypted) + { + decrypted = string.Empty; + if (base64Data.IsNullOrEmpty()) + { + return false; + } + + var pass = Utils.GetMd5(item.LoginPassword ?? string.Empty); + if (pass.Length != 32) + { + return false; + } + + try + { + var key = Convert.FromHexString(pass); + var raw = TryBase64DecodeBytes(base64Data); + if (raw == null || raw.Length <= 16) + { + return false; + } + + var iv = raw[..16]; + var cipher = raw[16..]; + + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var decryptor = aes.CreateDecryptor(); + var plainBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length); + decrypted = Encoding.UTF8.GetString(plainBytes); + return decrypted.IsNotEmpty(); + } + catch (Exception ex) + { + Logging.SaveLog("SubscriptionDecryptFailed", ex); + return false; + } + } + + private static byte[]? TryBase64DecodeBytes(string plainText) + { + try + { + if (plainText.IsNullOrEmpty()) + { + return null; + } + + plainText = plainText.Trim() + .Replace(Environment.NewLine, "") + .Replace("\n", "") + .Replace("\r", "") + .Replace('_', '/') + .Replace('-', '+') + .Replace(" ", ""); + + if (plainText.Length % 4 > 0) + { + plainText = plainText.PadRight(plainText.Length + 4 - (plainText.Length % 4), '='); + } + + return Convert.FromBase64String(plainText); + } + catch (Exception ex) + { + Logging.SaveLog("SubscriptionBase64DecodeFailed", ex); + return null; + } } private static async Task ProcessDownloadResult(Config config, string id, string result, string hashCode, Func updateFunc) diff --git a/v2rayN/ServiceLib/Models/SubItem.cs b/v2rayN/ServiceLib/Models/SubItem.cs index 612ec15b..3d91a03e 100644 --- a/v2rayN/ServiceLib/Models/SubItem.cs +++ b/v2rayN/ServiceLib/Models/SubItem.cs @@ -12,6 +12,8 @@ public class SubItem public string MoreUrl { get; set; } + public string? LoginPassword { get; set; } + public bool Enabled { get; set; } = true; public string UserAgent { get; set; } = string.Empty; diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index 6f223693..ba1bc551 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // 此代码由工具生成。 // 运行时版本:4.0.30319.42000 @@ -510,6 +510,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Login password 的本地化字符串。 + /// + public static string LvLoginPassword { + get { + return ResourceManager.GetString("LvLoginPassword", resourceCulture); + } + } + /// /// 查找类似 Remarks Memo 的本地化字符串。 /// @@ -2049,6 +2058,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Subscription decryption failed 的本地化字符串。 + /// + public static string MsgSubscriptionDecryptFailed { + get { + return ResourceManager.GetString("MsgSubscriptionDecryptFailed", resourceCulture); + } + } + /// /// 查找类似 Unpacking... 的本地化字符串。 /// @@ -2430,6 +2448,12 @@ namespace ServiceLib.Resx { /// /// 查找类似 For group please leave blank here 的本地化字符串。 /// + public static string SubLoginPasswordTips { + get { + return ResourceManager.GetString("SubLoginPasswordTips", resourceCulture); + } + } + public static string SubUrlTips { get { return ResourceManager.GetString("SubUrlTips", resourceCulture); diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index 041a103b..6e5b05b4 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -228,6 +228,9 @@ Invalid subscription content + + Subscription decryption failed + Unpacking... @@ -309,6 +312,9 @@ Remarks + + Login password + URL (optional) @@ -867,6 +873,9 @@ Supports DNS Object; Click to view documentation + + Please enter the website login password + For group please leave blank here diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx index c8936668..ff26ed41 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -228,6 +228,9 @@ 无效的订阅内容 + + 订阅解密失败 + 正在解压...... @@ -309,6 +312,9 @@ 别名 + + 登录密码 + 可选地址 (Url) @@ -867,6 +873,9 @@ 支持填写 DnsObject,JSON 格式,点击查看文档 + + 请输入网站登录密码 + 普通分组此处请留空 diff --git a/v2rayN/ServiceLib/Services/DownloadService.cs b/v2rayN/ServiceLib/Services/DownloadService.cs index 77d3a7c1..43ad657e 100644 --- a/v2rayN/ServiceLib/Services/DownloadService.cs +++ b/v2rayN/ServiceLib/Services/DownloadService.cs @@ -87,13 +87,22 @@ public class DownloadService } public async Task TryDownloadString(string url, bool blProxy, string userAgent) + { + var (content, _) = await TryDownloadStringWithHeaders(url, blProxy, userAgent); + return content; + } + + /// + /// Download string and return response headers (for subscription encryption detection). + /// + public async Task<(string? Content, HttpHeaders? ResponseHeaders)> TryDownloadStringWithHeaders(string url, bool blProxy, string userAgent) { try { - var result1 = await DownloadStringAsync(url, blProxy, userAgent, 15); - if (result1.IsNotEmpty()) + var (content, headers) = await DownloadStringWithHeadersAsync(url, blProxy, userAgent, 15); + if (content.IsNotEmpty()) { - return result1; + return (content, headers); } } catch (Exception ex) @@ -111,7 +120,7 @@ public class DownloadService var result2 = await DownloadStringViaDownloader(url, blProxy, userAgent, 15); if (result2.IsNotEmpty()) { - return result2; + return (result2, null); } } catch (Exception ex) @@ -124,7 +133,7 @@ public class DownloadService } } - return null; + return (null, null); } /// @@ -132,6 +141,12 @@ public class DownloadService /// /// private async Task DownloadStringAsync(string url, bool blProxy, string userAgent, int timeout) + { + var (content, _) = await DownloadStringWithHeadersAsync(url, blProxy, userAgent, timeout); + return content; + } + + private async Task<(string? Content, HttpHeaders? ResponseHeaders)> DownloadStringWithHeadersAsync(string url, bool blProxy, string userAgent, int timeout) { try { @@ -156,8 +171,10 @@ public class DownloadService } using var cts = new CancellationTokenSource(); - var result = await client.GetStringAsync(url, cts.Token).WaitAsync(TimeSpan.FromSeconds(timeout), cts.Token); - return result; + using var response = await client.GetAsync(url, cts.Token).WaitAsync(TimeSpan.FromSeconds(timeout), cts.Token); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(cts.Token); + return (content, response.Headers); } catch (Exception ex) { @@ -168,7 +185,7 @@ public class DownloadService Error?.Invoke(this, new ErrorEventArgs(ex.InnerException)); } } - return null; + return (null, null); } /// diff --git a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs index 8f5e9029..16af2e97 100644 --- a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs @@ -450,7 +450,22 @@ public class MainWindowViewModel : MyReactiveObject public async Task UpdateSubscriptionProcess(string subId, bool blProxy) { - await Task.Run(async () => await SubscriptionHandler.UpdateProcess(_config, subId, blProxy, UpdateTaskHandler)); + var decryptPromptShown = false; + await Task.Run(async () => await SubscriptionHandler.UpdateProcess( + _config, + subId, + blProxy, + UpdateTaskHandler, + async subItem => + { + if (decryptPromptShown || subItem?.Id.IsNullOrEmpty() != false) + { + return; + } + decryptPromptShown = true; + AppEvents.SubscriptionDecryptFailedRequested.Publish(subItem.Id); + await Task.CompletedTask; + })); } #endregion Subscription diff --git a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs index 5c5ba9cb..bd86664c 100644 --- a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs +++ b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs @@ -149,6 +149,12 @@ public partial class MainWindow : WindowBase .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(blShow => ShowHideWindow(blShow)) .DisposeWith(disposables); + + AppEvents.SubscriptionDecryptFailedRequested + .AsObservable() + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(async subId => await OpenSubEditForDecryptFailed(subId)) + .DisposeWith(disposables); }); if (Utils.IsWindows()) @@ -187,6 +193,21 @@ public partial class MainWindow : WindowBase await Task.CompletedTask; } + private async Task OpenSubEditForDecryptFailed(string subId) + { + if (subId.IsNullOrEmpty()) + { + return; + } + var lst = await AppManager.Instance.SubItems(); + var item = lst?.FirstOrDefault(x => x.Id == subId); + if (item == null) + { + return; + } + await new SubEditWindow(item, focusLoginPassword: true).ShowDialog(this); + } + private async Task UpdateViewHandler(EViewAction action, object? obj) { switch (action) diff --git a/v2rayN/v2rayN.Desktop/Views/SubEditWindow.axaml b/v2rayN/v2rayN.Desktop/Views/SubEditWindow.axaml index 79216921..49395bec 100644 --- a/v2rayN/v2rayN.Desktop/Views/SubEditWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/SubEditWindow.axaml @@ -69,6 +69,21 @@ VerticalAlignment="Center" TextWrapping="Wrap" Watermark="{x:Static resx:ResUI.SubUrlTips}" /> + + +