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
11 changed files with 177 additions and 10 deletions

View File

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

View File

@@ -16,6 +16,7 @@
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.5" />
<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="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />

View File

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

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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).",

View File

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

View File

@@ -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]

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