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:
bohdanbtw
2026-03-12 19:22:18 +00:00
committed by GitHub
parent 4b547e19c4
commit 69589075e5
16 changed files with 528 additions and 255 deletions
+2 -8
View File
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 18
VisualStudioVersion = 17.4.33205.214 VisualStudioVersion = 18.3.11520.95 d18.3
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "changelog", "changelog", "{161BFADE-FCF3-45D1-82EA-8A1B187529F7}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "changelog", "changelog", "{161BFADE-FCF3-45D1-82EA-8A1B187529F7}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
@@ -30,8 +30,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "changelog", "changelog", "{
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteamLibForked", "src\SteamLibForked\SteamLibForked.csproj", "{224F9DB0-3D20-A614-BA2A-12F22B13A2C6}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteamLibForked", "src\SteamLibForked\SteamLibForked.csproj", "{224F9DB0-3D20-A614-BA2A-12F22B13A2C6}"
EndProject 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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NebulaAuth", "src\NebulaAuth\NebulaAuth.csproj", "{C42F63B6-32F7-ED08-5B86-CD51989761AD}"
EndProject EndProject
Global Global
@@ -44,10 +42,6 @@ Global
{224F9DB0-3D20-A614-BA2A-12F22B13A2C6}.Debug|Any CPU.Build.0 = Debug|Any CPU {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.ActiveCfg = Release|Any CPU
{224F9DB0-3D20-A614-BA2A-12F22B13A2C6}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{C42F63B6-32F7-ED08-5B86-CD51989761AD}.Debug|Any CPU.Build.0 = 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 {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>
-176
View File
@@ -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();
}
+20
View File
@@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using MaterialDesignThemes.Wpf; using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
using NebulaAuth.Model; using NebulaAuth.Model;
using NebulaAuth.Model.Entities; using NebulaAuth.Model.Entities;
using NebulaAuth.View; using NebulaAuth.View;
@@ -51,6 +52,25 @@ public static class DialogsController
return null; 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() public static async Task<bool> ShowProxyManager()
{ {
var vm = new ProxyManagerVM(); 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);
}
}
}
+33 -10
View File
@@ -73,26 +73,46 @@ public static class Storage
/// </summary> /// </summary>
/// <param name="path"></param> /// <param name="path"></param>
/// <param name="overwrite"></param> /// <param name="overwrite"></param>
/// <exception cref="FormatException"></exception> /// <returns> Result of adding mafile</returns>
/// <exception cref="SessionInvalidException"></exception> /// <exception cref="SessionInvalidException"></exception>
/// <exception cref="IOException"></exception> public static async Task<AddMafileResult> AddNewMafile(string path, bool overwrite)
public static async Task AddNewMafile(string path, bool overwrite)
{ {
Mafile data; Mafile data;
try try
{ {
data = await ReadMafileAsync(path); data = await ReadMafileAsync(path);
data.Filename = null;
} }
catch (Exception ex) catch (Exception ex) when (ex is not MafileNeedReloginException)
when (ex is not MafileNeedReloginException)
{ {
Shell.Logger.Warn(ex, "Can't load mafile"); Shell.Logger.Warn(ex, "Can't load mafile");
throw new FormatException("File data is not valid", ex); 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)) 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 try
{ {
@@ -100,16 +120,19 @@ public static class Storage
} }
catch (Exception ex) 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))) 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); await SaveMafileAsync(data);
return AddMafileResult.Added;
} }
public static async Task<Mafile> ReadMafileAsync(string path) 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);
}
}
}
@@ -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. #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 all
#pragma warning disable SYSLIB0023 #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> /// <summary>
/// Tries to decrypt and return data given an encrypted base64 encoded string. Must use the same /// 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. /// password, salt, IV, and ciphertext that was used during the original encryption of the data.
@@ -4,15 +4,15 @@
//Pragma disable for legacy code //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("encrypted")] public bool Encrypted { get; set; }
[JsonProperty("first_run")] public bool FirstRun { 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; } [JsonProperty("periodic_checking")] public bool PeriodicChecking { get; set; }
@@ -28,7 +28,7 @@ public class Manifest
[JsonProperty("auto_confirm_trades")] public bool AutoConfirmTrades { get; set; } [JsonProperty("auto_confirm_trades")] public bool AutoConfirmTrades { get; set; }
} }
public class Entry public class SDAManifestEntry
{ {
[JsonProperty("encryption_iv")] public string EncryptionIv { get; set; } [JsonProperty("encryption_iv")] public string EncryptionIv { get; set; }
+3
View File
@@ -53,6 +53,9 @@
<Compile Update="View\Dialogs\LoginAgainOnImportDialog.xaml.cs"> <Compile Update="View\Dialogs\LoginAgainOnImportDialog.xaml.cs">
<SubType>Code</SubType> <SubType>Code</SubType>
</Compile> </Compile>
<Compile Update="View\Dialogs\SdaPasswordDialog.xaml.cs">
<SubType>Code</SubType>
</Compile>
</ItemGroup> </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();
}
}
+95 -37
View File
@@ -14,6 +14,7 @@ using NebulaAuth.Model;
using NebulaAuth.Model.Entities; using NebulaAuth.Model.Entities;
using NebulaAuth.Model.Exceptions; using NebulaAuth.Model.Exceptions;
using NebulaAuth.Model.Mafiles; using NebulaAuth.Model.Mafiles;
using NebulaAuth.Model.MafilesLegacy;
using NebulaAuth.Utility; using NebulaAuth.Utility;
using NebulaAuth.View; using NebulaAuth.View;
using NebulaAuth.View.Dialogs; using NebulaAuth.View.Dialogs;
@@ -26,6 +27,7 @@ public partial class MainVM //File //TODO: Refactor
{ {
public Settings Settings => Settings.Instance; public Settings Settings => Settings.Instance;
[RelayCommand] [RelayCommand]
private void OpenMafileFolder() private void OpenMafileFolder()
{ {
@@ -58,48 +60,50 @@ public partial class MainVM //File //TODO: Refactor
[RelayCommand] [RelayCommand]
private Task AddMafile() private Task AddMafile()
{ {
var openFileDialog = new OpenFileDialog var dialog = new OpenFileDialog
{ {
Filter = "Mafile|*.mafile;*.maFile", Filter = "Mafile|*.mafile;*.maFile",
Multiselect = false Multiselect = false
}; };
var fs = openFileDialog.ShowDialog();
if (fs != true) return Task.CompletedTask; return dialog.ShowDialog() == true
var path = openFileDialog.FileName; ? AddMafile([dialog.FileName])
return AddMafile([path]); : Task.CompletedTask;
} }
public async Task AddMafile(string[] path) public async Task AddMafile(string[] path)
{ {
bool? confirmOverwrite = null; bool? confirmOverwrite = null;
var added = 0; var summary = new MafileImportSummary();
var notAdded = 0; SDAEncryptionHelper.Context? sdaContext = null;
var errors = 0; var sdaPasswordPrompted = false;
foreach (var str in path) foreach (var str in path)
{ {
try try
{ {
await Storage.AddNewMafile(str, confirmOverwrite ?? false); var (mafile, passwordPrompted, context) = await TryReadMafile(str, sdaContext, sdaPasswordPrompted);
added++; 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) catch (FormatException)
{ {
errors++; summary.ErrorOne();
}
catch (IOException)
{
confirmOverwrite ??=
await DialogsController.ShowConfirmCancelDialog(GetLocalization("ConfirmMafileOverwrite"));
if (confirmOverwrite == true)
{
await Storage.AddNewMafile(str, true);
added++;
}
else if (confirmOverwrite == false)
{
notAdded++;
}
} }
catch (MafileNeedReloginException ex) catch (MafileNeedReloginException ex)
{ {
@@ -108,11 +112,11 @@ public partial class MainVM //File //TODO: Refactor
var mafile = ex.Mafile; var mafile = ex.Mafile;
if (await HandleAddMafileWithoutSession(mafile)) if (await HandleAddMafileWithoutSession(mafile))
{ {
added++; summary.AddedOne();
} }
else else
{ {
errors++; summary.ErrorOne();
} }
} }
else else
@@ -124,23 +128,50 @@ public partial class MainVM //File //TODO: Refactor
} }
} }
var msg = GetLocalization("Import"); ShowImportSummary(summary);
if (added > 0)
{
msg += $" {GetLocalization("ImportAdded")} {added}.";
} }
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) private async Task<bool> HandleAddMafileWithoutSession(Mafile data)
@@ -191,6 +222,28 @@ public partial class MainVM //File //TODO: Refactor
return result; 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] [RelayCommand]
private async Task RemoveMafile() private async Task RemoveMafile()
{ {
@@ -294,4 +347,9 @@ public partial class MainVM //File //TODO: Refactor
if (mafile is not Mafile maf) return false; if (mafile is not Mafile maf) return false;
return maf.Password != null && PHandler.IsPasswordSet; 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);
}
+56
View File
@@ -1061,6 +1061,44 @@
"fr": "Confirmer l'action" "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": { "TextFieldDialog": {
"Title": { "Title": {
"ru": "Введите значение", "ru": "Введите значение",
@@ -1407,6 +1445,24 @@
"ua": "Помилка імпорту мафайла", "ua": "Помилка імпорту мафайла",
"fr": "Erreur d'importation du mafile" "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": { "MissingSessionInMafile": {
"ru": ". У него отсутствует сессия. Добавьте этот файл отдельно от остальных", "ru": ". У него отсутствует сессия. Добавьте этот файл отдельно от остальных",
"en": ". It has no session. Add this file separately from the others", "en": ". It has no session. Add this file separately from the others",