mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-03-15 19:32:31 +00:00
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:
1
.github/workflows/main.yml
vendored
1
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -52,8 +52,10 @@ public partial class LocalizationManager
|
||||
[nameof(AutoUpdateLabel)] = "Авто-оновлення",
|
||||
[nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску",
|
||||
[nameof(PersistTokenLabel)] = "Зберігати токен",
|
||||
[nameof(PersistTokenTooltip)] =
|
||||
"Зберігати останній використаний токен у файлі для збереження між сеансами",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
Зберігати останній використаний токен у файлі для збереження між сеансами.
|
||||
**Увага**: хоча токен зберігається із шифруванням, він може бути відновлений зловмисником, який має доступ до вашої системи.
|
||||
""",
|
||||
[nameof(RateLimitPreferenceLabel)] = "Ліміт запитів",
|
||||
[nameof(RateLimitPreferenceTooltip)] =
|
||||
"Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).",
|
||||
|
||||
@@ -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]
|
||||
|
||||
90
DiscordChatExporter.Gui/Services/TokenEncryptionConverter.cs
Normal file
90
DiscordChatExporter.Gui/Services/TokenEncryptionConverter.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user