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