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;
+ }
+ }
+ }
+}