mirror of
https://github.com/achiez/NebulaAuth-Steam-Desktop-Authenticator-by-Achies.git
synced 2026-07-25 06:14:31 +00:00
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.
This commit is contained in:
+2
-8
@@ -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
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SteamLibForked\SteamLibForked.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<Manifest>(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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the SDA encryption password dialog for unlocking SDA-encrypted mafiles.
|
||||
/// </summary>
|
||||
/// <returns>The entered password if user clicked OK, otherwise null.</returns>
|
||||
public static async Task<string?> 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<bool> ShowProxyManager()
|
||||
{
|
||||
var vm = new ProxyManagerVM();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace NebulaAuth.Model.Mafiles;
|
||||
|
||||
public enum AddMafileResult
|
||||
{
|
||||
Added,
|
||||
AlreadyExist,
|
||||
Error
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,26 +73,46 @@ public static class Storage
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <param name="overwrite"></param>
|
||||
/// <exception cref="FormatException"></exception>
|
||||
/// <returns> Result of adding mafile</returns>
|
||||
/// <exception cref="SessionInvalidException"></exception>
|
||||
/// <exception cref="IOException"></exception>
|
||||
public static async Task AddNewMafile(string path, bool overwrite)
|
||||
public static async Task<AddMafileResult> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
/// <param name="overwrite"></param>
|
||||
/// <returns> Result of adding mafile</returns>
|
||||
/// <exception cref="SessionInvalidException"></exception>
|
||||
public static Task<AddMafileResult> AddNewMafileFromData(Mafile data, bool overwrite)
|
||||
{
|
||||
return AddNewMafileInternal(data, overwrite);
|
||||
}
|
||||
|
||||
private static async Task<AddMafileResult> 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<Mafile> ReadMafileAsync(string path)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace NebulaAuth.Model.MafilesLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<byte> 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<SDAManifest>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of SDA encrypted mafile detection. When not null, the caller can decrypt
|
||||
/// using SDAEncryptor.DecryptData(password, Entry.EncryptionSalt, Entry.EncryptionIv, fileContent).
|
||||
/// </summary>
|
||||
public sealed class Context
|
||||
{
|
||||
public SDAManifest SdaManifest { get; }
|
||||
public Dictionary<string, SDAManifestEntry> Entries { get; }
|
||||
public string? Password { get; }
|
||||
|
||||
public Context(SDAManifest sdaManifest, Dictionary<string, SDAManifestEntry> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
+4
-4
@@ -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; }
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
<Compile Update="View\Dialogs\LoginAgainOnImportDialog.xaml.cs">
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Update="View\Dialogs\SdaPasswordDialog.xaml.cs">
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<UserControl x:Class="NebulaAuth.View.Dialogs.SdaPasswordDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
xmlns:other="clr-namespace:NebulaAuth.ViewModel.Other"
|
||||
mc:Ignorable="d"
|
||||
TextElement.Foreground="{DynamicResource BaseContentBrush}"
|
||||
d:DataContext="{d:DesignInstance other:SdaPasswordDialogVM}"
|
||||
Background="{DynamicResource WindowBackground}">
|
||||
|
||||
<Grid MinHeight="200" MinWidth="400" MaxWidth="500">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid Margin="10,10,10,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<materialDesign:PackIcon Kind="Lock" Width="20" Height="20" Margin="0,0,5,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource PrimaryContentBrush}" />
|
||||
<TextBlock FontSize="16" VerticalAlignment="Center" Foreground="{DynamicResource BaseContentBrush}"
|
||||
Margin="7,0,0,0" HorizontalAlignment="Left"
|
||||
Text="{Tr SdaPasswordDialog.Title}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Width="30" Height="30" Style="{StaticResource MaterialDesignIconForegroundButton}"
|
||||
HorizontalAlignment="Right" CommandParameter="{StaticResource False}"
|
||||
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}">
|
||||
<materialDesign:PackIcon Kind="Close" Width="24" Height="24" Foreground="IndianRed" />
|
||||
</Button>
|
||||
</Grid>
|
||||
<Separator Grid.Row="1" Opacity="0.7" />
|
||||
<Grid Grid.Row="2" Margin="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" Text="{Tr SdaPasswordDialog.Message}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource BaseContentBrush}" Margin="10,10,10,8" />
|
||||
<TextBox Grid.Row="1" Text="{Binding Password, UpdateSourceTrigger=PropertyChanged}"
|
||||
materialDesign:TextFieldAssist.HasClearButton="True"
|
||||
Padding="10" FontSize="15" Margin="10,0,10,0"
|
||||
Style="{StaticResource MaterialDesignOutlinedTextBox}"
|
||||
materialDesign:HintAssist.Hint="{Tr SdaPasswordDialog.PasswordHint}"
|
||||
materialDesign:HintAssist.IsFloating="False"
|
||||
materialDesign:TextFieldAssist.LeadingIcon="Key"
|
||||
materialDesign:TextFieldAssist.HasLeadingIcon="True" />
|
||||
<Grid Grid.Row="2" Margin="10,16,10,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition />
|
||||
<ColumnDefinition />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button IsEnabled="{Binding IsFormValid}" IsDefault="True" Margin="0,5,5,5"
|
||||
Style="{StaticResource MaterialDesignOutlinedButton}"
|
||||
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
|
||||
CommandParameter="{StaticResource True}" Content="{Tr Common.OK}" />
|
||||
<Button IsCancel="True" Grid.Column="1" Margin="5,5,0,5"
|
||||
Style="{StaticResource MaterialDesignOutlinedButton}"
|
||||
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
|
||||
CommandParameter="{StaticResource False}" Content="{Tr Common.Cancel}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace NebulaAuth.View.Dialogs;
|
||||
|
||||
public partial class SdaPasswordDialog
|
||||
{
|
||||
public SdaPasswordDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ using NebulaAuth.Model;
|
||||
using NebulaAuth.Model.Entities;
|
||||
using NebulaAuth.Model.Exceptions;
|
||||
using NebulaAuth.Model.Mafiles;
|
||||
using NebulaAuth.Model.MafilesLegacy;
|
||||
using NebulaAuth.Utility;
|
||||
using NebulaAuth.View;
|
||||
using NebulaAuth.View.Dialogs;
|
||||
@@ -26,6 +27,7 @@ public partial class MainVM //File //TODO: Refactor
|
||||
{
|
||||
public Settings Settings => Settings.Instance;
|
||||
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenMafileFolder()
|
||||
{
|
||||
@@ -58,48 +60,50 @@ public partial class MainVM //File //TODO: Refactor
|
||||
[RelayCommand]
|
||||
private Task AddMafile()
|
||||
{
|
||||
var openFileDialog = new OpenFileDialog
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Filter = "Mafile|*.mafile;*.maFile",
|
||||
Multiselect = false
|
||||
};
|
||||
var fs = openFileDialog.ShowDialog();
|
||||
if (fs != true) return Task.CompletedTask;
|
||||
var path = openFileDialog.FileName;
|
||||
return AddMafile([path]);
|
||||
|
||||
return dialog.ShowDialog() == true
|
||||
? AddMafile([dialog.FileName])
|
||||
: Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task AddMafile(string[] path)
|
||||
{
|
||||
bool? confirmOverwrite = null;
|
||||
var added = 0;
|
||||
var notAdded = 0;
|
||||
var errors = 0;
|
||||
var summary = new MafileImportSummary();
|
||||
SDAEncryptionHelper.Context? sdaContext = null;
|
||||
var sdaPasswordPrompted = false;
|
||||
foreach (var str in path)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Storage.AddNewMafile(str, confirmOverwrite ?? false);
|
||||
added++;
|
||||
var (mafile, passwordPrompted, context) = await TryReadMafile(str, sdaContext, sdaPasswordPrompted);
|
||||
sdaContext = context;
|
||||
sdaPasswordPrompted = passwordPrompted;
|
||||
if (mafile == null)
|
||||
{
|
||||
summary.ErrorOne();
|
||||
continue;
|
||||
}
|
||||
|
||||
var overwrite = confirmOverwrite ?? false;
|
||||
var res = await Storage.AddNewMafileFromData(mafile, overwrite);
|
||||
if (res == AddMafileResult.AlreadyExist && confirmOverwrite == null)
|
||||
{
|
||||
confirmOverwrite =
|
||||
await DialogsController.ShowConfirmCancelDialog(GetLocalization("ConfirmMafileOverwrite"));
|
||||
res = await Storage.AddNewMafileFromData(mafile, confirmOverwrite.Value);
|
||||
}
|
||||
|
||||
summary.Apply(res);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
errors++;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
confirmOverwrite ??=
|
||||
await DialogsController.ShowConfirmCancelDialog(GetLocalization("ConfirmMafileOverwrite"));
|
||||
|
||||
if (confirmOverwrite == true)
|
||||
{
|
||||
await Storage.AddNewMafile(str, true);
|
||||
added++;
|
||||
}
|
||||
else if (confirmOverwrite == false)
|
||||
{
|
||||
notAdded++;
|
||||
}
|
||||
summary.ErrorOne();
|
||||
}
|
||||
catch (MafileNeedReloginException ex)
|
||||
{
|
||||
@@ -108,11 +112,11 @@ public partial class MainVM //File //TODO: Refactor
|
||||
var mafile = ex.Mafile;
|
||||
if (await HandleAddMafileWithoutSession(mafile))
|
||||
{
|
||||
added++;
|
||||
summary.AddedOne();
|
||||
}
|
||||
else
|
||||
{
|
||||
errors++;
|
||||
summary.ErrorOne();
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -124,23 +128,50 @@ public partial class MainVM //File //TODO: Refactor
|
||||
}
|
||||
}
|
||||
|
||||
var msg = GetLocalization("Import");
|
||||
if (added > 0)
|
||||
{
|
||||
msg += $" {GetLocalization("ImportAdded")} {added}.";
|
||||
ShowImportSummary(summary);
|
||||
}
|
||||
|
||||
if (notAdded > 0)
|
||||
|
||||
private async Task<MafileReadResult> TryReadMafile(string path, SDAEncryptionHelper.Context? sdaContext,
|
||||
bool sdaPasswordPrompted)
|
||||
{
|
||||
msg += $" {GetLocalization("ImportSkipped")} {notAdded}.";
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path);
|
||||
Mafile mafile;
|
||||
if (!SDAEncryptionHelper.LooksLikeSdaEncryptedBlob(content))
|
||||
{
|
||||
mafile = NebulaSerializer.Deserialize(content, path);
|
||||
return new MafileReadResult(mafile, sdaPasswordPrompted, sdaContext);
|
||||
}
|
||||
|
||||
if (errors > 0)
|
||||
// Looks like encrypted, let's see if we have manifest
|
||||
sdaContext ??= SDAEncryptionHelper.TryDetect(path, sdaContext?.SdaManifest);
|
||||
if (sdaContext != null)
|
||||
{
|
||||
msg += $" {GetLocalization("ImportErrors")} {errors}.";
|
||||
// We have manifest, but we must ensure we have password
|
||||
if (sdaContext.Password == null && !sdaPasswordPrompted)
|
||||
{
|
||||
sdaPasswordPrompted = true;
|
||||
var password = await DialogsController.ShowSdaPasswordDialog();
|
||||
sdaContext = sdaContext.WithPassword(password);
|
||||
}
|
||||
|
||||
SnackbarController.SendSnackbar(msg, TimeSpan.FromSeconds(2));
|
||||
var decrypted = SDAEncryptionHelper.TryDecrypt(content, path, sdaContext);
|
||||
if (decrypted == null) return new MafileReadResult(null, sdaPasswordPrompted, sdaContext);
|
||||
content = decrypted;
|
||||
}
|
||||
|
||||
// If we are here, it means that either file is not encrypted, or we successfully decrypted it
|
||||
mafile = NebulaSerializer.Deserialize(content, path);
|
||||
return new MafileReadResult(mafile, sdaPasswordPrompted, sdaContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
when (ex is not MafileNeedReloginException)
|
||||
{
|
||||
Shell.Logger.Warn(ex, "Failed to import mafile");
|
||||
throw new FormatException($"Failed to read mafile {Path.GetFileName(path)}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> HandleAddMafileWithoutSession(Mafile data)
|
||||
@@ -191,6 +222,28 @@ public partial class MainVM //File //TODO: Refactor
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ShowImportSummary(MafileImportSummary summary)
|
||||
{
|
||||
var msg = GetLocalization("Import");
|
||||
if (summary.Added > 0)
|
||||
{
|
||||
msg += $" {GetLocalization("ImportAdded")} {summary.Added}.";
|
||||
}
|
||||
|
||||
if (summary.NotAdded > 0)
|
||||
{
|
||||
msg += $" {GetLocalization("ImportSkipped")} {summary.NotAdded}.";
|
||||
}
|
||||
|
||||
if (summary.Errors > 0)
|
||||
{
|
||||
msg += $" {GetLocalization("ImportErrors")} {summary.Errors}.";
|
||||
}
|
||||
|
||||
SnackbarController.SendSnackbar(msg, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveMafile()
|
||||
{
|
||||
@@ -294,4 +347,9 @@ public partial class MainVM //File //TODO: Refactor
|
||||
if (mafile is not Mafile maf) return false;
|
||||
return maf.Password != null && PHandler.IsPasswordSet;
|
||||
}
|
||||
|
||||
private record MafileReadResult(
|
||||
Mafile? Mafile,
|
||||
bool SdaPasswordPrompted,
|
||||
SDAEncryptionHelper.Context? SdaContext);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace NebulaAuth.ViewModel.Other;
|
||||
|
||||
public partial class SdaPasswordDialogVM : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsFormValid))]
|
||||
private string _password = string.Empty;
|
||||
|
||||
public bool IsFormValid => !string.IsNullOrWhiteSpace(Password);
|
||||
}
|
||||
@@ -1061,6 +1061,44 @@
|
||||
"fr": "Confirmer l'action"
|
||||
}
|
||||
},
|
||||
"SdaPasswordDialog": {
|
||||
"Title": {
|
||||
"en": "SDA encryption password",
|
||||
"ru": "Пароль шифрования SDA",
|
||||
"ua": "Пароль шифрування SDA",
|
||||
"fr": "Mot de passe de chiffrement SDA"
|
||||
},
|
||||
"Message": {
|
||||
"en": "This mafile is encrypted with Steam Desktop Authenticator. Enter your SDA encryption password to import it.",
|
||||
"ru": "Этот mafile зашифрован Steam Desktop Authenticator. Введите пароль шифрования SDA для импорта.",
|
||||
"ua": "Цей mafile зашифровано Steam Desktop Authenticator. Введіть пароль шифрування SDA для імпорту.",
|
||||
"fr": "Ce mafile est chiffré avec Steam Desktop Authenticator. Entrez votre mot de passe SDA pour l'importer."
|
||||
},
|
||||
"PasswordHint": {
|
||||
"en": "SDA encryption password",
|
||||
"ru": "Пароль шифрования SDA",
|
||||
"ua": "Пароль шифрування SDA",
|
||||
"fr": "Mot de passe de chiffrement SDA"
|
||||
},
|
||||
"WrongPassword": {
|
||||
"en": "Wrong SDA password. Please try again.",
|
||||
"ru": "Неверный пароль SDA. Попробуйте снова.",
|
||||
"ua": "Невірний пароль SDA. Спробуйте ще раз.",
|
||||
"fr": "Mauvais mot de passe SDA. Veuillez réessayer."
|
||||
},
|
||||
"ManifestMissing": {
|
||||
"en": "This mafile looks like it was encrypted by SDA, but manifest.json was not found. Please select your SDA manifest.json to continue.",
|
||||
"ru": "Похоже, этот mafile зашифрован SDA, но manifest.json не найден. Выберите manifest.json от SDA, чтобы продолжить.",
|
||||
"ua": "Схоже, цей mafile зашифровано SDA, але manifest.json не знайдено. Виберіть manifest.json від SDA, щоб продовжити.",
|
||||
"fr": "Ce mafile semble chiffré par SDA, mais manifest.json est introuvable. Sélectionnez manifest.json de SDA pour continuer."
|
||||
},
|
||||
"ManifestEntryNotFound": {
|
||||
"en": "Selected manifest.json does not contain an entry for this mafile.",
|
||||
"ru": "Выбранный manifest.json не содержит запись для этого mafile.",
|
||||
"ua": "Вибраний manifest.json не містить запису для цього mafile.",
|
||||
"fr": "Le manifest.json sélectionné ne contient pas d'entrée pour ce mafile."
|
||||
}
|
||||
},
|
||||
"TextFieldDialog": {
|
||||
"Title": {
|
||||
"ru": "Введите значение",
|
||||
@@ -1407,6 +1445,24 @@
|
||||
"ua": "Помилка імпорту мафайла",
|
||||
"fr": "Erreur d'importation du mafile"
|
||||
},
|
||||
"SdaManifestMissing": {
|
||||
"en": "This .mafile is encrypted by Steam Desktop Authenticator (SDA).\r\n\r\nTo import it, select the .mafile from the SDA folder (so Nebula can find the matching manifest.json), or click OK and manually select SDA's manifest.json.\r\n\r\nWithout manifest.json, decrypting is impossible because it contains the encryption salt and IV.",
|
||||
"ru": "Этот .mafile зашифрован Steam Desktop Authenticator (SDA).\r\n\r\nЧтобы импортировать его, выберите .mafile из папки SDA (чтобы Nebula нашла соответствующий manifest.json), либо нажмите OK и вручную выберите manifest.json от SDA.\r\n\r\nБез manifest.json расшифровка невозможна, так как в нём хранятся salt и IV.",
|
||||
"ua": "Цей .mafile зашифровано Steam Desktop Authenticator (SDA).\r\n\r\nЩоб імпортувати його, виберіть .mafile з папки SDA (щоб Nebula знайшла відповідний manifest.json), або натисніть OK і вручну виберіть manifest.json від SDA.\r\n\r\nБез manifest.json розшифрування неможливе, бо там зберігаються salt та IV.",
|
||||
"fr": "Ce .mafile est chiffré par Steam Desktop Authenticator (SDA).\r\n\r\nPour l'importer, sélectionnez le .mafile depuis le dossier SDA (afin que Nebula trouve le manifest.json correspondant), ou cliquez sur OK et sélectionnez manuellement le manifest.json de SDA.\r\n\r\nSans manifest.json, le déchiffrement est impossible car il contient le sel et l'IV."
|
||||
},
|
||||
"SdaManifestEntryNotFound": {
|
||||
"en": "The selected manifest.json does not contain an entry for this .mafile.",
|
||||
"ru": "Выбранный manifest.json не содержит запись для этого .mafile.",
|
||||
"ua": "Вибраний manifest.json не містить запису для цього .mafile.",
|
||||
"fr": "Le manifest.json sélectionné ne contient pas d'entrée pour ce .mafile."
|
||||
},
|
||||
"SdaWrongPassword": {
|
||||
"en": "Wrong SDA password. Please try again.",
|
||||
"ru": "Неверный пароль SDA. Попробуйте снова.",
|
||||
"ua": "Невірний пароль SDA. Спробуйте ще раз.",
|
||||
"fr": "Mauvais mot de passe SDA. Veuillez réessayer."
|
||||
},
|
||||
"MissingSessionInMafile": {
|
||||
"ru": ". У него отсутствует сессия. Добавьте этот файл отдельно от остальных",
|
||||
"en": ". It has no session. Add this file separately from the others",
|
||||
|
||||
Reference in New Issue
Block a user