Encrypt Discord token at rest in settings file (machine-bound) (#1491)

* 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>
This commit is contained in:
Copilot 2026-02-27 14:01:25 +02:00 committed by GitHub
parent 2e47c73388
commit eef0fc742d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 177 additions and 10 deletions

View file

@ -127,6 +127,7 @@ jobs:
dotnet publish ${{ matrix.app }} dotnet publish ${{ matrix.app }}
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }} -p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
-p:CSharpier_Bypass=true -p:CSharpier_Bypass=true
-p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }} -p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
--output ${{ matrix.app }}/bin/publish/ --output ${{ matrix.app }}/bin/publish/
--configuration Release --configuration Release

View file

@ -16,6 +16,7 @@
<PackageVersion Include="coverlet.collector" Version="6.0.4" /> <PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.5" /> <PackageVersion Include="CSharpier.MsBuild" Version="1.2.5" />
<PackageVersion Include="Deorcify" Version="1.1.0" /> <PackageVersion Include="Deorcify" Version="1.1.0" />
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.10.4" /> <PackageVersion Include="DialogHost.Avalonia" Version="0.10.4" />
<PackageVersion Include="FluentAssertions" Version="8.8.0" /> <PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" /> <PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />

View file

@ -8,6 +8,15 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<EncryptionSalt>HimalayanPinkSalt</EncryptionSalt>
</PropertyGroup>
<ItemGroup>
<!-- Expose this property in code -->
<ProjectProperty Include="EncryptionSalt" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<PublishMacOSBundle>false</PublishMacOSBundle> <PublishMacOSBundle>false</PublishMacOSBundle>
</PropertyGroup> </PropertyGroup>
@ -38,6 +47,7 @@
<PackageReference Include="Material.Icons.Avalonia" /> <PackageReference Include="Material.Icons.Avalonia" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Onova" /> <PackageReference Include="Onova" />
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -52,8 +52,10 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Auto-update", [nameof(AutoUpdateLabel)] = "Auto-update",
[nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch", [nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch",
[nameof(PersistTokenLabel)] = "Persist token", [nameof(PersistTokenLabel)] = "Persist token",
[nameof(PersistTokenTooltip)] = [nameof(PersistTokenTooltip)] = """
"Save the last used token to a file so that it can be persisted between sessions", 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(RateLimitPreferenceLabel)] = "Rate limit preference",
[nameof(RateLimitPreferenceTooltip)] = [nameof(RateLimitPreferenceTooltip)] =
"Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.", "Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.",

View file

@ -54,8 +54,10 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Mise à jour automatique", [nameof(AutoUpdateLabel)] = "Mise à jour automatique",
[nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement", [nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement",
[nameof(PersistTokenLabel)] = "Conserver le token", [nameof(PersistTokenLabel)] = "Conserver le token",
[nameof(PersistTokenTooltip)] = [nameof(PersistTokenTooltip)] = """
"Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions", 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(RateLimitPreferenceLabel)] = "Préférence de limite de débit",
[nameof(RateLimitPreferenceTooltip)] = [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.", "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.",

View file

@ -54,8 +54,10 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Automatische Updates", [nameof(AutoUpdateLabel)] = "Automatische Updates",
[nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen", [nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen",
[nameof(PersistTokenLabel)] = "Token speichern", [nameof(PersistTokenLabel)] = "Token speichern",
[nameof(PersistTokenTooltip)] = [nameof(PersistTokenTooltip)] = """
"Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt", 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(RateLimitPreferenceLabel)] = "Ratenlimit-Einstellung",
[nameof(RateLimitPreferenceTooltip)] = [nameof(RateLimitPreferenceTooltip)] =
"Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.", "Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.",

View file

@ -52,8 +52,10 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Actualización automática", [nameof(AutoUpdateLabel)] = "Actualización automática",
[nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio", [nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio",
[nameof(PersistTokenLabel)] = "Guardar token", [nameof(PersistTokenLabel)] = "Guardar token",
[nameof(PersistTokenTooltip)] = [nameof(PersistTokenTooltip)] = """
"Guardar el último token utilizado en un archivo para conservarlo entre sesiones", 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(RateLimitPreferenceLabel)] = "Preferencia de límite de velocidad",
[nameof(RateLimitPreferenceTooltip)] = [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).", "Si se deben respetar los límites de velocidad recomendados. Si está desactivado, solo se respetarán los límites estrictos (respuestas 429).",

View file

@ -52,8 +52,10 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Авто-оновлення", [nameof(AutoUpdateLabel)] = "Авто-оновлення",
[nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску", [nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску",
[nameof(PersistTokenLabel)] = "Зберігати токен", [nameof(PersistTokenLabel)] = "Зберігати токен",
[nameof(PersistTokenTooltip)] = [nameof(PersistTokenTooltip)] = """
"Зберігати останній використаний токен у файлі для збереження між сеансами", Зберігати останній використаний токен у файлі для збереження між сеансами.
**Увага**: хоча токен зберігається із шифруванням, він може бути відновлений зловмисником, який має доступ до вашої системи.
""",
[nameof(RateLimitPreferenceLabel)] = "Ліміт запитів", [nameof(RateLimitPreferenceLabel)] = "Ліміт запитів",
[nameof(RateLimitPreferenceTooltip)] = [nameof(RateLimitPreferenceTooltip)] =
"Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).", "Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).",

View file

@ -50,6 +50,7 @@ public partial class SettingsService()
public partial int ParallelLimit { get; set; } = 1; public partial int ParallelLimit { get; set; } = 1;
[ObservableProperty] [ObservableProperty]
[JsonConverter(typeof(TokenEncryptionConverter))]
public partial string? LastToken { get; set; } public partial string? LastToken { get; set; }
[ObservableProperty] [ObservableProperty]

View file

@ -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<string?>
{
private const string Prefix = "enc:";
private static readonly Lazy<byte[]> 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));
}
}

View file

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