From 69589075e5dcbf68b5c8492732e998f784926c2f Mon Sep 17 00:00:00 2001 From: bohdanbtw <127321482+bohdanbtw@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:22:18 +0000 Subject: [PATCH] feat(import): support SDA-encrypted mafiles (#17) NebulaAuth previously supported only plain JSON `.mafile` files. Steam Desktop Authenticator (SDA) can encrypt these files and stores per-file salt and IV values in `manifest.json`, which prevented users from importing accounts protected with SDA encryption. Add support for importing SDA-encrypted mafiles. The importer now detects encrypted blobs, automatically locates the SDA manifest, prompts for the SDA password once, and decrypts files during import. The import flow was also refactored to unify handling of plain and encrypted mafiles. SDA detection and decryption logic is moved into a dedicated helper, batch imports reuse the entered password, and the ViewModel import logic was simplified with explicit result handling instead of exception-driven control flow. This allows users to migrate accounts from Steam Desktop Authenticator to NebulaAuth even when their mafiles are encrypted. --- NebulaAuth.sln | 10 +- .../NebulaAuth.LegacyConverter.csproj | 14 -- src/NebulaAuth.LegacyConverter/Program.cs | 176 ------------------ src/NebulaAuth/Core/DialogsController.cs | 20 ++ .../Model/Mafiles/AddMafileResult.cs | 8 + .../Model/Mafiles/MafileImportSummary.cs | 50 +++++ src/NebulaAuth/Model/Mafiles/Storage.cs | 43 ++++- .../MafilesLegacy/SDAEncryptionHelper.cs | 142 ++++++++++++++ .../Model/MafilesLegacy/SDAEncryptor.cs} | 21 ++- .../Model/MafilesLegacy/SDAManifest.cs} | 8 +- src/NebulaAuth/NebulaAuth.csproj | 3 + .../View/Dialogs/SdaPasswordDialog.xaml | 71 +++++++ .../View/Dialogs/SdaPasswordDialog.xaml.cs | 9 + src/NebulaAuth/ViewModel/MainVM_File.cs | 140 ++++++++++---- .../ViewModel/Other/SdaPasswordDialogVM.cs | 12 ++ src/NebulaAuth/localization.loc.json | 56 ++++++ 16 files changed, 528 insertions(+), 255 deletions(-) delete mode 100644 src/NebulaAuth.LegacyConverter/NebulaAuth.LegacyConverter.csproj delete mode 100644 src/NebulaAuth.LegacyConverter/Program.cs create mode 100644 src/NebulaAuth/Model/Mafiles/AddMafileResult.cs create mode 100644 src/NebulaAuth/Model/Mafiles/MafileImportSummary.cs create mode 100644 src/NebulaAuth/Model/MafilesLegacy/SDAEncryptionHelper.cs rename src/{NebulaAuth.LegacyConverter/SDADecryptor.cs => NebulaAuth/Model/MafilesLegacy/SDAEncryptor.cs} (92%) rename src/{NebulaAuth.LegacyConverter/EncryptedManifest.cs => NebulaAuth/Model/MafilesLegacy/SDAManifest.cs} (86%) create mode 100644 src/NebulaAuth/View/Dialogs/SdaPasswordDialog.xaml create mode 100644 src/NebulaAuth/View/Dialogs/SdaPasswordDialog.xaml.cs create mode 100644 src/NebulaAuth/ViewModel/Other/SdaPasswordDialogVM.cs diff --git a/NebulaAuth.sln b/NebulaAuth.sln index 7133794..521d5fa 100644 --- a/NebulaAuth.sln +++ b/NebulaAuth.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.4.33205.214 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11520.95 d18.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "changelog", "changelog", "{161BFADE-FCF3-45D1-82EA-8A1B187529F7}" ProjectSection(SolutionItems) = preProject @@ -30,8 +30,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "changelog", "changelog", "{ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteamLibForked", "src\SteamLibForked\SteamLibForked.csproj", "{224F9DB0-3D20-A614-BA2A-12F22B13A2C6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NebulaAuth.LegacyConverter", "src\NebulaAuth.LegacyConverter\NebulaAuth.LegacyConverter.csproj", "{C8235305-E5C4-155B-C718-C0F239CA3AB7}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NebulaAuth", "src\NebulaAuth\NebulaAuth.csproj", "{C42F63B6-32F7-ED08-5B86-CD51989761AD}" EndProject Global @@ -44,10 +42,6 @@ Global {224F9DB0-3D20-A614-BA2A-12F22B13A2C6}.Debug|Any CPU.Build.0 = Debug|Any CPU {224F9DB0-3D20-A614-BA2A-12F22B13A2C6}.Release|Any CPU.ActiveCfg = Release|Any CPU {224F9DB0-3D20-A614-BA2A-12F22B13A2C6}.Release|Any CPU.Build.0 = Release|Any CPU - {C8235305-E5C4-155B-C718-C0F239CA3AB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C8235305-E5C4-155B-C718-C0F239CA3AB7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C8235305-E5C4-155B-C718-C0F239CA3AB7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C8235305-E5C4-155B-C718-C0F239CA3AB7}.Release|Any CPU.Build.0 = Release|Any CPU {C42F63B6-32F7-ED08-5B86-CD51989761AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C42F63B6-32F7-ED08-5B86-CD51989761AD}.Debug|Any CPU.Build.0 = Debug|Any CPU {C42F63B6-32F7-ED08-5B86-CD51989761AD}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/src/NebulaAuth.LegacyConverter/NebulaAuth.LegacyConverter.csproj b/src/NebulaAuth.LegacyConverter/NebulaAuth.LegacyConverter.csproj deleted file mode 100644 index fa02bbb..0000000 --- a/src/NebulaAuth.LegacyConverter/NebulaAuth.LegacyConverter.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - Exe - net8.0 - enable - enable - - - - - - - diff --git a/src/NebulaAuth.LegacyConverter/Program.cs b/src/NebulaAuth.LegacyConverter/Program.cs deleted file mode 100644 index 64f48a5..0000000 --- a/src/NebulaAuth.LegacyConverter/Program.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Reflection; -using AchiesUtilities.Extensions; -using NebulaAuth.LegacyConverter; -using Newtonsoft.Json; -using SteamLib.Utility.MafileSerialization; - -try -{ - var mafileSerializer = new MafileSerializer(new MafileSerializerSettings - { - DeserializationOptions = - { - AllowDeviceIdGeneration = true, - AllowSessionIdGeneration = true - } - }); - var currentPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - if (currentPath != null) - Environment.CurrentDirectory = currentPath; - const string toStoreFolder = "ConvertedMafiles"; - - if (Directory.Exists(toStoreFolder) == false) - { - Directory.CreateDirectory(toStoreFolder); - } - else - { - var isEmpty = Directory.GetFiles(toStoreFolder).Length == 0; - Console.ForegroundColor = ConsoleColor.Yellow; - if (!isEmpty) - { - Console.WriteLine( - "WARNING! 'ConverterdMafiles' folder is not empty. Please backup data from it and then continue."); - Console.ResetColor(); - Console.WriteLine("Press Y to continue"); - while (Console.ReadKey(true).Key != ConsoleKey.Y) - { - } - } - } - - var decryptMode = false; - while (true) - { - Console.WriteLine("Press 'D' to select decrypt mode. Press 'C' to convert mode. Press ESC to exit"); - var key = Console.ReadKey(true); - switch (key.Key) - { - case ConsoleKey.D: - decryptMode = true; - break; - case ConsoleKey.C: - break; - case ConsoleKey.Escape: - return; - default: - continue; - } - - break; - } - - - Manifest? manifest = null; - string? password = null; - if (decryptMode) - { - var files = Directory.GetFiles(currentPath, "manifest.json"); - var manifestPath = files.FirstOrDefault(); - if (manifestPath == null) - { - Console.WriteLine("No manifest.json found in current directory"); - return; - } - - var manifestText = File.ReadAllText(manifestPath); - try - { - manifest = JsonConvert.DeserializeObject(manifestText)!; - } - catch (Exception ex) - { - Console.WriteLine("Can't read manifest: " + ex); - return; - } - - if (manifest.Encrypted == false) - { - Console.WriteLine("Manifest is not encrypted"); - return; - } - - while (true) - { - Console.WriteLine("Please enter your encryption password: "); - password = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(password)) - continue; - - break; - } - } - - - Console.WriteLine(currentPath); - - - foreach (var path in args) - { - if (Path.Exists(path) == false) - { - Console.WriteLine($"NOT VALID PATH: '{path}'"); - continue; - } - - Console.WriteLine("Reading: " + path); - try - { - var text = File.ReadAllText(path); - if (decryptMode) - { - var fileName = Path.GetFileName(path); - text = DecryptMafile(fileName, text); - if (text == null) - { - Console.WriteLine(path + " not found in manifest. Skipped"); - continue; - } - } - - var data = mafileSerializer.Deserialize(text); - var maf = data.Data; - var legacy = MafileSerializer.SerializeLegacy(maf, Formatting.Indented); - var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path); - Write(legacy, fileNameWithoutExtension); - - Console.WriteLine("DONE: " + fileNameWithoutExtension); - } - catch (Exception ex) - { - Console.WriteLine($"ERROR: {ex.Message}"); - Console.WriteLine(ex); - } - finally - { - Console.WriteLine("-----------------------------------------"); - } - } - - //Local Functions - - void Write(string maf, string name) - { - var path = Path.Combine(toStoreFolder, name + "_legacy.mafile"); - File.WriteAllText(path, maf); - } - - string? DecryptMafile(string fileName, string cipherText) - { - if (password == null) return null; - var entry = manifest?.Entries.FirstOrDefault(x => x.Filename.EqualsIgnoreCase(fileName)); - if (entry == null) - { - return null; - } - - var iv = entry.EncryptionIv; - var salt = entry.EncryptionSalt; - return SDAEncryptor.DecryptData(password, salt, iv, cipherText); - } -} -finally -{ - Console.WriteLine("Press any key to exit..."); - Console.ReadKey(); -} \ No newline at end of file diff --git a/src/NebulaAuth/Core/DialogsController.cs b/src/NebulaAuth/Core/DialogsController.cs index 7459707..4bb0f4c 100644 --- a/src/NebulaAuth/Core/DialogsController.cs +++ b/src/NebulaAuth/Core/DialogsController.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using MaterialDesignThemes.Wpf; +using Microsoft.Win32; using NebulaAuth.Model; using NebulaAuth.Model.Entities; using NebulaAuth.View; @@ -51,6 +52,25 @@ public static class DialogsController return null; } + /// + /// Shows the SDA encryption password dialog for unlocking SDA-encrypted mafiles. + /// + /// The entered password if user clicked OK, otherwise null. + public static async Task ShowSdaPasswordDialog() + { + var vm = new SdaPasswordDialogVM(); + var content = new SdaPasswordDialog + { + DataContext = vm + }; + var result = await DialogHost.Show(content); + if (result is true) + { + return vm.Password; + } + + return null; + } public static async Task ShowProxyManager() { var vm = new ProxyManagerVM(); diff --git a/src/NebulaAuth/Model/Mafiles/AddMafileResult.cs b/src/NebulaAuth/Model/Mafiles/AddMafileResult.cs new file mode 100644 index 0000000..9749fd2 --- /dev/null +++ b/src/NebulaAuth/Model/Mafiles/AddMafileResult.cs @@ -0,0 +1,8 @@ +namespace NebulaAuth.Model.Mafiles; + +public enum AddMafileResult +{ + Added, + AlreadyExist, + Error +} \ No newline at end of file diff --git a/src/NebulaAuth/Model/Mafiles/MafileImportSummary.cs b/src/NebulaAuth/Model/Mafiles/MafileImportSummary.cs new file mode 100644 index 0000000..b22f395 --- /dev/null +++ b/src/NebulaAuth/Model/Mafiles/MafileImportSummary.cs @@ -0,0 +1,50 @@ +using System; + +namespace NebulaAuth.Model.Mafiles; + +public class MafileImportSummary +{ + public int Added { get; private set; } + public int NotAdded { get; private set; } + public int Errors { get; private set; } + + public void AddedOne() + { + Added++; + } + + public void SkippedOne() + { + NotAdded++; + } + + public void ErrorOne() + { + Errors++; + } + + public void Add(int added = 0, int notAdded = 0, int errors = 0) + { + Added += added; + NotAdded += notAdded; + Errors += errors; + } + + public void Apply(AddMafileResult result) + { + switch (result) + { + case AddMafileResult.Added: + AddedOne(); + break; + case AddMafileResult.AlreadyExist: + SkippedOne(); + break; + case AddMafileResult.Error: + ErrorOne(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } +} \ No newline at end of file diff --git a/src/NebulaAuth/Model/Mafiles/Storage.cs b/src/NebulaAuth/Model/Mafiles/Storage.cs index e116832..c2f71cc 100644 --- a/src/NebulaAuth/Model/Mafiles/Storage.cs +++ b/src/NebulaAuth/Model/Mafiles/Storage.cs @@ -73,26 +73,46 @@ public static class Storage /// /// /// - /// + /// Result of adding mafile /// - /// - public static async Task AddNewMafile(string path, bool overwrite) + public static async Task AddNewMafile(string path, bool overwrite) { Mafile data; + try { data = await ReadMafileAsync(path); - data.Filename = null; } - catch (Exception ex) - when (ex is not MafileNeedReloginException) + catch (Exception ex) when (ex is not MafileNeedReloginException) { Shell.Logger.Warn(ex, "Can't load mafile"); throw new FormatException("File data is not valid", ex); } + await AddNewMafileInternal(data, overwrite); + return AddMafileResult.Added; + } + + /// + /// + /// + /// + /// Result of adding mafile + /// + public static Task AddNewMafileFromData(Mafile data, bool overwrite) + { + return AddNewMafileInternal(data, overwrite); + } + + private static async Task AddNewMafileInternal(Mafile data, bool overwrite) + { + ArgumentNullException.ThrowIfNull(data); + if (string.IsNullOrWhiteSpace(data.AccountName)) - throw new FormatException("File data is not valid. Missing AccountName"); + { + Shell.Logger.Warn("Mafile account name is empty"); + return AddMafileResult.Error; + } try { @@ -100,16 +120,19 @@ public static class Storage } catch (Exception ex) { - throw new FormatException("Can't generate code on this mafile", ex); + Shell.Logger.Error(ex, "Can't generate code for mafile {accountName}", data.AccountName); + return AddMafileResult.Error; } + data.Filename = null; + if (!overwrite && File.Exists(MafilesStorageHelper.GetOrUpdateMafilePath(data))) { - throw new IOException("File already exist and overwrite is False"); //TODO: Custom Exception + return AddMafileResult.AlreadyExist; } - await SaveMafileAsync(data); + return AddMafileResult.Added; } public static async Task ReadMafileAsync(string path) diff --git a/src/NebulaAuth/Model/MafilesLegacy/SDAEncryptionHelper.cs b/src/NebulaAuth/Model/MafilesLegacy/SDAEncryptionHelper.cs new file mode 100644 index 0000000..3d1b1c3 --- /dev/null +++ b/src/NebulaAuth/Model/MafilesLegacy/SDAEncryptionHelper.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; + +namespace NebulaAuth.Model.MafilesLegacy; + +/// +/// Helper class for detecting and handling SDA encrypted mafiles. +/// This is used in the legacy mafile handling code to support importing SDA encrypted mafiles without requiring the +/// user to manually decrypt them first. +/// +public static class SDAEncryptionHelper +{ + private const string ManifestFileName = "manifest.json"; + + public static bool LooksLikeSdaEncryptedBlob(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return false; + + var trimmed = content.Trim(); + + if (trimmed.StartsWith('{') || trimmed.Length < 64) + return false; + + Span buffer = stackalloc byte[trimmed.Length]; + return Convert.TryFromBase64String(trimmed, buffer, out _); + } + + public static Context? TryDetect(string mafilePath, SDAManifest? sdaManifest) + { + if (string.IsNullOrWhiteSpace(mafilePath)) + return null; + + if (sdaManifest == null) + { + var dir = Path.GetDirectoryName(mafilePath); + if (string.IsNullOrWhiteSpace(dir)) + return null; + + var manifestPath = FindManifestPath(dir); + if (manifestPath == null) + return null; + + sdaManifest = TryReadEncryptedManifest(manifestPath); + } + + if (sdaManifest is not {Encrypted: true} || sdaManifest.Entries.Length == 0) + return null; + + var fileName = Path.GetFileName(mafilePath); + if (string.IsNullOrWhiteSpace(fileName)) + return null; + + var entries = sdaManifest.Entries.ToDictionary(x => Path.GetFileName(x.Filename), StringComparer.OrdinalIgnoreCase); + return new Context(sdaManifest, entries, null); + } + + private static string? FindManifestPath(string startDirectory) + { + var current = startDirectory; + for (var i = 0; i < 2; i++) + { + var candidate = Path.Combine(current, ManifestFileName); + if (File.Exists(candidate)) + return candidate; + + var parent = Directory.GetParent(current); + if (parent?.FullName == null) + break; + current = parent.FullName; + } + + return null; + } + + private static SDAManifest? TryReadEncryptedManifest(string manifestPath) + { + try + { + var manifestText = File.ReadAllText(manifestPath); + var manifest = JsonConvert.DeserializeObject(manifestText); + if (manifest is not {Encrypted: true} || manifest.Entries.Length == 0) + return null; + return manifest; + } + catch (Exception) + { + return null; + } + } + + public static string? TryDecrypt(string content, string path, Context context) + { + if (string.IsNullOrWhiteSpace(context.Password)) + return null; + + // If we have password, check if we have entry + var entry = context.GetEntry(path); + if (entry == null) + { + return null; + } + + // We have entry, try to decrypt + return SDAEncryptor.TryDecryptData(context.Password, entry.EncryptionSalt, entry.EncryptionIv, content, + out var decrypted) + ? decrypted + : null; + } + + /// + /// Result of SDA encrypted mafile detection. When not null, the caller can decrypt + /// using SDAEncryptor.DecryptData(password, Entry.EncryptionSalt, Entry.EncryptionIv, fileContent). + /// + public sealed class Context + { + public SDAManifest SdaManifest { get; } + public Dictionary Entries { get; } + public string? Password { get; } + + public Context(SDAManifest sdaManifest, Dictionary entries, string? password) + { + SdaManifest = sdaManifest; + Entries = entries; + Password = password; + } + + public Context WithPassword(string? password) + { + return new Context(SdaManifest, Entries, password); + } + + public SDAManifestEntry? GetEntry(string path) + { + var fileName = Path.GetFileName(path); + return Entries.GetValueOrDefault(fileName); + } + } +} \ No newline at end of file diff --git a/src/NebulaAuth.LegacyConverter/SDADecryptor.cs b/src/NebulaAuth/Model/MafilesLegacy/SDAEncryptor.cs similarity index 92% rename from src/NebulaAuth.LegacyConverter/SDADecryptor.cs rename to src/NebulaAuth/Model/MafilesLegacy/SDAEncryptor.cs index a1989b5..0c5daa6 100644 --- a/src/NebulaAuth.LegacyConverter/SDADecryptor.cs +++ b/src/NebulaAuth/Model/MafilesLegacy/SDAEncryptor.cs @@ -1,7 +1,10 @@ -using System.Security.Cryptography; +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Security.Cryptography; #pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. -namespace NebulaAuth.LegacyConverter; +namespace NebulaAuth.Model.MafilesLegacy; #pragma warning disable all #pragma warning disable SYSLIB0023 @@ -83,6 +86,20 @@ public static class SDAEncryptor } } + public static bool TryDecryptData(string password, string passwordSalt, string IV, string encryptedData, [NotNullWhen(true)] out string? plaintext) + { + plaintext = null; + try + { + plaintext = DecryptData(password, passwordSalt, IV, encryptedData); + return plaintext != null; + } + catch (Exception) + { + return false; + } + } + /// /// Tries to decrypt and return data given an encrypted base64 encoded string. Must use the same /// password, salt, IV, and ciphertext that was used during the original encryption of the data. diff --git a/src/NebulaAuth.LegacyConverter/EncryptedManifest.cs b/src/NebulaAuth/Model/MafilesLegacy/SDAManifest.cs similarity index 86% rename from src/NebulaAuth.LegacyConverter/EncryptedManifest.cs rename to src/NebulaAuth/Model/MafilesLegacy/SDAManifest.cs index 8a8f0c4..105e835 100644 --- a/src/NebulaAuth.LegacyConverter/EncryptedManifest.cs +++ b/src/NebulaAuth/Model/MafilesLegacy/SDAManifest.cs @@ -4,15 +4,15 @@ //Pragma disable for legacy code -namespace NebulaAuth.LegacyConverter; +namespace NebulaAuth.Model.MafilesLegacy; -public class Manifest +public class SDAManifest { [JsonProperty("encrypted")] public bool Encrypted { get; set; } [JsonProperty("first_run")] public bool FirstRun { get; set; } - [JsonProperty("entries")] public Entry[] Entries { get; set; } + [JsonProperty("entries")] public SDAManifestEntry[] Entries { get; set; } [JsonProperty("periodic_checking")] public bool PeriodicChecking { get; set; } @@ -28,7 +28,7 @@ public class Manifest [JsonProperty("auto_confirm_trades")] public bool AutoConfirmTrades { get; set; } } -public class Entry +public class SDAManifestEntry { [JsonProperty("encryption_iv")] public string EncryptionIv { get; set; } diff --git a/src/NebulaAuth/NebulaAuth.csproj b/src/NebulaAuth/NebulaAuth.csproj index d27c630..e6b5c1d 100644 --- a/src/NebulaAuth/NebulaAuth.csproj +++ b/src/NebulaAuth/NebulaAuth.csproj @@ -53,6 +53,9 @@ Code + + Code + diff --git a/src/NebulaAuth/View/Dialogs/SdaPasswordDialog.xaml b/src/NebulaAuth/View/Dialogs/SdaPasswordDialog.xaml new file mode 100644 index 0000000..8736d1e --- /dev/null +++ b/src/NebulaAuth/View/Dialogs/SdaPasswordDialog.xaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +