From eef0fc742dca2d30957f0b5d9403d5c227e2ab11 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:01:25 +0200 Subject: [PATCH] Encrypt Discord token at rest in settings file (machine-bound) (#1491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Add token encryption when saving/loading settings Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Apply suggestion from @Tyrrrz * Apply suggestion from @Tyrrrz * Bind token encryption key to machine identity Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Switch to AES-GCM, hex encoding, and GetBytes/Fill improvements Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Address all review feedback: salt injection, code style, localization formatting Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Address latest review: ThisAssembly.Project, EnvironmentExtensions, inline Lazy, renames, localization wording Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Address latest review: layout comment, cipherSource, else block, MachineName fallback, csproj ordering Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Apply suggestion from @Tyrrrz * Rename GetMachineId→TryGetMachineId, refactor Write to use single array with FillBytes Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Rename cipherSource→cipher in Read(), tokenBytes→tokenData in Write(), update layout comments Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Add cipherSource variable in Write(), update layout comment with size annotation Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Fix CSharpier formatting: inline multiline string assignments and reformat exception filter Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Quote EncryptionSalt argument to handle single quotes in secret value Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Revert double-quote fix on EncryptionSalt argument Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> * Apply suggestion from @Tyrrrz * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/main.yml | 1 + Directory.Packages.props | 1 + .../DiscordChatExporter.Gui.csproj | 10 +++ .../LocalizationManager.English.cs | 6 +- .../LocalizationManager.French.cs | 6 +- .../LocalizationManager.German.cs | 6 +- .../LocalizationManager.Spanish.cs | 6 +- .../LocalizationManager.Ukrainian.cs | 6 +- .../Services/SettingsService.cs | 1 + .../Services/TokenEncryptionConverter.cs | 90 +++++++++++++++++++ .../Utils/Extensions/EnvironmentExtensions.cs | 54 +++++++++++ 11 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 DiscordChatExporter.Gui/Services/TokenEncryptionConverter.cs create mode 100644 DiscordChatExporter.Gui/Utils/Extensions/EnvironmentExtensions.cs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5bccbc84..eccfb44a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -127,6 +127,7 @@ jobs: dotnet publish ${{ matrix.app }} -p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }} -p:CSharpier_Bypass=true + -p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }} -p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }} --output ${{ matrix.app }}/bin/publish/ --configuration Release diff --git a/Directory.Packages.props b/Directory.Packages.props index 45b9afe3..f4cfb435 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + diff --git a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj index ad3070d3..e5e27df3 100644 --- a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj +++ b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj @@ -8,6 +8,15 @@ true + + HimalayanPinkSalt + + + + + + + false @@ -38,6 +47,7 @@ + diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.English.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.English.cs index 7f7fc479..165ac567 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.English.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.English.cs @@ -52,8 +52,10 @@ public partial class LocalizationManager [nameof(AutoUpdateLabel)] = "Auto-update", [nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch", [nameof(PersistTokenLabel)] = "Persist token", - [nameof(PersistTokenTooltip)] = - "Save the last used token to a file so that it can be persisted between sessions", + [nameof(PersistTokenTooltip)] = """ + Save the last used token to a file so that it can be persisted between sessions. + **Warning**: although the token is stored with encryption, it may still be recovered by an attacker who has access to your system. + """, [nameof(RateLimitPreferenceLabel)] = "Rate limit preference", [nameof(RateLimitPreferenceTooltip)] = "Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.", diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.French.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.French.cs index c6ffe41f..d9892eb9 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.French.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.French.cs @@ -54,8 +54,10 @@ public partial class LocalizationManager [nameof(AutoUpdateLabel)] = "Mise à jour automatique", [nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement", [nameof(PersistTokenLabel)] = "Conserver le token", - [nameof(PersistTokenTooltip)] = - "Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions", + [nameof(PersistTokenTooltip)] = """ + Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions. + **Avertissement** : bien que le token soit stocké avec chiffrement, il peut toujours être récupéré par un attaquant ayant accès à votre système. + """, [nameof(RateLimitPreferenceLabel)] = "Préférence de limite de débit", [nameof(RateLimitPreferenceTooltip)] = "Indique s'il faut respecter les limites de débit recommandées. Si désactivé, seules les limites strictes (réponses 429) seront respectées.", diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.German.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.German.cs index 05eec130..bbd312c4 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.German.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.German.cs @@ -54,8 +54,10 @@ public partial class LocalizationManager [nameof(AutoUpdateLabel)] = "Automatische Updates", [nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen", [nameof(PersistTokenLabel)] = "Token speichern", - [nameof(PersistTokenTooltip)] = - "Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt", + [nameof(PersistTokenTooltip)] = """ + Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt. + **Warnung**: Der Token wird mit Verschlüsselung gespeichert, kann aber dennoch von einem Angreifer mit Zugriff auf Ihr System wiederhergestellt werden. + """, [nameof(RateLimitPreferenceLabel)] = "Ratenlimit-Einstellung", [nameof(RateLimitPreferenceTooltip)] = "Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.", diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.Spanish.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.Spanish.cs index 61912430..a323b528 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.Spanish.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.Spanish.cs @@ -52,8 +52,10 @@ public partial class LocalizationManager [nameof(AutoUpdateLabel)] = "Actualización automática", [nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio", [nameof(PersistTokenLabel)] = "Guardar token", - [nameof(PersistTokenTooltip)] = - "Guardar el último token utilizado en un archivo para conservarlo entre sesiones", + [nameof(PersistTokenTooltip)] = """ + Guardar el último token utilizado en un archivo para conservarlo entre sesiones. + **Advertencia**: aunque el token se almacena con cifrado, aún puede ser recuperado por un atacante con acceso a tu sistema. + """, [nameof(RateLimitPreferenceLabel)] = "Preferencia de límite de velocidad", [nameof(RateLimitPreferenceTooltip)] = "Si se deben respetar los límites de velocidad recomendados. Si está desactivado, solo se respetarán los límites estrictos (respuestas 429).", diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.Ukrainian.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.Ukrainian.cs index b3ce2f48..841de495 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.Ukrainian.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.Ukrainian.cs @@ -52,8 +52,10 @@ public partial class LocalizationManager [nameof(AutoUpdateLabel)] = "Авто-оновлення", [nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску", [nameof(PersistTokenLabel)] = "Зберігати токен", - [nameof(PersistTokenTooltip)] = - "Зберігати останній використаний токен у файлі для збереження між сеансами", + [nameof(PersistTokenTooltip)] = """ + Зберігати останній використаний токен у файлі для збереження між сеансами. + **Увага**: хоча токен зберігається із шифруванням, він може бути відновлений зловмисником, який має доступ до вашої системи. + """, [nameof(RateLimitPreferenceLabel)] = "Ліміт запитів", [nameof(RateLimitPreferenceTooltip)] = "Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).", diff --git a/DiscordChatExporter.Gui/Services/SettingsService.cs b/DiscordChatExporter.Gui/Services/SettingsService.cs index f2a06709..ebd4ca78 100644 --- a/DiscordChatExporter.Gui/Services/SettingsService.cs +++ b/DiscordChatExporter.Gui/Services/SettingsService.cs @@ -50,6 +50,7 @@ public partial class SettingsService() public partial int ParallelLimit { get; set; } = 1; [ObservableProperty] + [JsonConverter(typeof(TokenEncryptionConverter))] public partial string? LastToken { get; set; } [ObservableProperty] diff --git a/DiscordChatExporter.Gui/Services/TokenEncryptionConverter.cs b/DiscordChatExporter.Gui/Services/TokenEncryptionConverter.cs new file mode 100644 index 00000000..c9e70d6c --- /dev/null +++ b/DiscordChatExporter.Gui/Services/TokenEncryptionConverter.cs @@ -0,0 +1,90 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using DiscordChatExporter.Gui.Utils.Extensions; + +namespace DiscordChatExporter.Gui.Services; + +internal class TokenEncryptionConverter : JsonConverter +{ + private const string Prefix = "enc:"; + + private static readonly Lazy Key = new(() => + Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(Environment.TryGetMachineId() ?? string.Empty), + Encoding.UTF8.GetBytes(ThisAssembly.Project.EncryptionSalt), + iterations: 10_000, + HashAlgorithmName.SHA256, + outputLength: 16 + ) + ); + + public override string? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + var value = reader.GetString(); + + // No prefix means the token is stored as plain text, which was + // the case for older versions of the application. + // Load it as is and encrypt it on next save. + if (string.IsNullOrWhiteSpace(value) || !value.StartsWith(Prefix, StringComparison.Ordinal)) + return value; + + try + { + var data = Convert.FromHexString(value[Prefix.Length..]); + + // Layout: nonce (12 bytes) | paddingLength (1 byte) | tag (16 bytes) | cipher + var nonce = data.AsSpan(0, 12); + var paddingLength = data[12]; + var tag = data.AsSpan(13, 16); + var cipher = data.AsSpan(29); + + var decrypted = new byte[cipher.Length]; + using var aes = new AesGcm(Key.Value, 16); + aes.Decrypt(nonce, cipher, tag, decrypted); + + return Encoding.UTF8.GetString(decrypted.AsSpan(paddingLength)); + } + catch (Exception ex) + when (ex + is FormatException + or CryptographicException + or ArgumentException + or IndexOutOfRangeException + ) + { + return null; + } + } + + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) + { + if (string.IsNullOrWhiteSpace(value)) + { + writer.WriteNullValue(); + return; + } + + var paddingLength = RandomNumberGenerator.GetInt32(1, 17); + var tokenData = Encoding.UTF8.GetBytes(value); + + // Layout: nonce (12 bytes) | paddingLength (1 byte) | tag (16 bytes) | cipher (paddingLength + tokenData.Length) + var data = new byte[29 + paddingLength + tokenData.Length]; + RandomNumberGenerator.Fill(data.AsSpan(0, 12)); // nonce + data[12] = (byte)paddingLength; + var cipherSource = data.AsSpan(29); + RandomNumberGenerator.Fill(cipherSource[..paddingLength]); // random padding + tokenData.CopyTo(cipherSource[paddingLength..]); // token + + using var aes = new AesGcm(Key.Value, 16); + aes.Encrypt(data.AsSpan(0, 12), cipherSource, cipherSource, data.AsSpan(13, 16)); + + writer.WriteStringValue(Prefix + Convert.ToHexStringLower(data)); + } +} diff --git a/DiscordChatExporter.Gui/Utils/Extensions/EnvironmentExtensions.cs b/DiscordChatExporter.Gui/Utils/Extensions/EnvironmentExtensions.cs new file mode 100644 index 00000000..8390309c --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/Extensions/EnvironmentExtensions.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; + +namespace DiscordChatExporter.Gui.Utils.Extensions; + +internal static class EnvironmentExtensions +{ + extension(Environment) + { + public static string? TryGetMachineId() + { + // Windows: stable GUID written during OS installation + if (OperatingSystem.IsWindows()) + { + try + { + using var regKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Cryptography" + ); + if ( + regKey?.GetValue("MachineGuid") is string guid + && !string.IsNullOrWhiteSpace(guid) + ) + return guid; + } + catch { } + } + else + { + // Unix: /etc/machine-id (set once by systemd at first boot) + foreach (var path in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" }) + { + try + { + var id = File.ReadAllText(path).Trim(); + if (!string.IsNullOrWhiteSpace(id)) + return id; + } + catch { } + } + } + + // Last-resort fallback + try + { + return Environment.MachineName; + } + catch + { + return null; + } + } + } +}