Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/MauiSherpa.Core/Interfaces.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public interface IDialogService
Task<string?> ShowInputDialogAsync(string title, string message, string placeholder = "");
Task<string?> ShowFileDialogAsync(string title, bool isSave = false, string[]? filters = null, string? defaultFileName = null);
Task<string?> PickFolderAsync(string title);
Task<string?> PickOpenFileAsync(string title, string[]? extensions = null);
Task<string?> PickSaveFileAsync(string title, string suggestedName, string extension);
Task CopyToClipboardAsync(string text);
}

Expand Down Expand Up @@ -1945,3 +1947,83 @@ public interface ISecretsPublisherService
/// </summary>
event Action? OnPublishersChanged;
}

// ============================================================================
// Encrypted Settings Storage
// ============================================================================

/// <summary>
/// Unified settings data model for MauiSherpa
/// </summary>
public record MauiSherpaSettings
{
public int Version { get; init; } = 1;
public List<AppleIdentityData> AppleIdentities { get; init; } = new();
public List<CloudProviderData> CloudProviders { get; init; } = new();
public string? ActiveCloudProviderId { get; init; }
public List<SecretsPublisherData> SecretsPublishers { get; init; } = new();
public AppPreferences Preferences { get; init; } = new();
public DateTime LastModified { get; init; } = DateTime.UtcNow;
}

public record AppleIdentityData(
string Id,
string Name,
string KeyId,
string IssuerId,
string P8Content,
DateTime CreatedAt
);

public record CloudProviderData(
string Id,
string Name,
CloudSecretsProviderType ProviderType,
Dictionary<string, string> Settings,
bool IsActive = false
);

public record SecretsPublisherData(
string Id,
string ProviderId,
string Name,
Dictionary<string, string> Settings
);

public record AppPreferences
{
public string Theme { get; init; } = "System";
public string? AndroidSdkPath { get; init; }
public bool AutoBackupEnabled { get; init; } = true;
}

/// <summary>
/// Service for managing encrypted application settings
/// </summary>
public interface IEncryptedSettingsService
{
Task<MauiSherpaSettings> GetSettingsAsync();
Task SaveSettingsAsync(MauiSherpaSettings settings);
Task UpdateSettingsAsync(Func<MauiSherpaSettings, MauiSherpaSettings> transform);
Task<bool> SettingsExistAsync();
event Action? OnSettingsChanged;
}

/// <summary>
/// Service for backup and restore operations
/// </summary>
public interface IBackupService
{
Task<byte[]> ExportSettingsAsync(string password);
Task<MauiSherpaSettings> ImportSettingsAsync(byte[] encryptedData, string password);
Task<bool> ValidateBackupAsync(byte[] data);
}

/// <summary>
/// Service for migrating settings from legacy storage
/// </summary>
public interface ISettingsMigrationService
{
Task<bool> NeedsMigrationAsync();
Task MigrateAsync();
}
136 changes: 136 additions & 0 deletions src/MauiSherpa.Core/Services/BackupService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using MauiSherpa.Core.Interfaces;

namespace MauiSherpa.Core.Services;

/// <summary>
/// Backup service for password-protected export/import of settings
/// Uses PBKDF2 for key derivation and AES-256-GCM for encryption
/// </summary>
public class BackupService : IBackupService
{
private const int SaltSize = 32;
private const int NonceSize = 12;
private const int TagSize = 16;
private const int KeySize = 32;
private const int Iterations = 100_000;
private static readonly byte[] MagicHeader = "MSSBAK01"u8.ToArray();

private readonly IEncryptedSettingsService _settingsService;

public BackupService(IEncryptedSettingsService settingsService)
{
_settingsService = settingsService;
}

public async Task<byte[]> ExportSettingsAsync(string password)
{
if (string.IsNullOrEmpty(password))
throw new ArgumentException("Password is required", nameof(password));

var settings = await _settingsService.GetSettingsAsync();
var json = JsonSerializer.Serialize(settings);
var plaintext = Encoding.UTF8.GetBytes(json);

// Generate salt and derive key
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var key = DeriveKey(password, salt);

// Encrypt with AES-GCM
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[TagSize];

using var aes = new AesGcm(key, TagSize);
aes.Encrypt(nonce, plaintext, ciphertext, tag);

// Format: [magic 8][salt 32][nonce 12][tag 16][ciphertext]
var result = new byte[MagicHeader.Length + SaltSize + NonceSize + TagSize + ciphertext.Length];
var offset = 0;

Buffer.BlockCopy(MagicHeader, 0, result, offset, MagicHeader.Length);
offset += MagicHeader.Length;

Buffer.BlockCopy(salt, 0, result, offset, SaltSize);
offset += SaltSize;

Buffer.BlockCopy(nonce, 0, result, offset, NonceSize);
offset += NonceSize;

Buffer.BlockCopy(tag, 0, result, offset, TagSize);
offset += TagSize;

Buffer.BlockCopy(ciphertext, 0, result, offset, ciphertext.Length);

return result;
}

public async Task<MauiSherpaSettings> ImportSettingsAsync(byte[] encryptedData, string password)
{
if (string.IsNullOrEmpty(password))
throw new ArgumentException("Password is required", nameof(password));

if (!await ValidateBackupAsync(encryptedData))
throw new InvalidOperationException("Invalid backup file format");

var minLength = MagicHeader.Length + SaltSize + NonceSize + TagSize;
if (encryptedData.Length < minLength)
throw new InvalidOperationException("Backup file is too small");

var offset = MagicHeader.Length;

var salt = new byte[SaltSize];
Buffer.BlockCopy(encryptedData, offset, salt, 0, SaltSize);
offset += SaltSize;

var nonce = new byte[NonceSize];
Buffer.BlockCopy(encryptedData, offset, nonce, 0, NonceSize);
offset += NonceSize;

var tag = new byte[TagSize];
Buffer.BlockCopy(encryptedData, offset, tag, 0, TagSize);
offset += TagSize;

var ciphertext = new byte[encryptedData.Length - offset];
Buffer.BlockCopy(encryptedData, offset, ciphertext, 0, ciphertext.Length);

// Derive key and decrypt
var key = DeriveKey(password, salt);
var plaintext = new byte[ciphertext.Length];

using var aes = new AesGcm(key, TagSize);
aes.Decrypt(nonce, ciphertext, tag, plaintext);

var json = Encoding.UTF8.GetString(plaintext);
var settings = JsonSerializer.Deserialize<MauiSherpaSettings>(json)
?? throw new InvalidOperationException("Failed to deserialize settings");

return settings;
}

public Task<bool> ValidateBackupAsync(byte[] data)
{
if (data == null || data.Length < MagicHeader.Length)
return Task.FromResult(false);

for (int i = 0; i < MagicHeader.Length; i++)
{
if (data[i] != MagicHeader[i])
return Task.FromResult(false);
}

return Task.FromResult(true);
}

private static byte[] DeriveKey(string password, byte[] salt)
{
using var pbkdf2 = new Rfc2898DeriveBytes(
password,
salt,
Iterations,
HashAlgorithmName.SHA256);
return pbkdf2.GetBytes(KeySize);
}
}
164 changes: 164 additions & 0 deletions src/MauiSherpa.Core/Services/EncryptedSettingsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using MauiSherpa.Core.Interfaces;

namespace MauiSherpa.Core.Services;

/// <summary>
/// Encrypted settings service using AES-256-GCM with OS keychain for master key
/// </summary>
public class EncryptedSettingsService : IEncryptedSettingsService
{
private const string MasterKeyStorageKey = "MauiSherpa_MasterKey";
private const string SettingsFileName = "settings.enc";
private const int NonceSize = 12;
private const int TagSize = 16;
private const int KeySize = 32; // 256 bits

private readonly IFileSystemService _fileSystem;
private readonly ISecureStorageService _secureStorage;
private readonly string _settingsPath;
private MauiSherpaSettings? _cachedSettings;
private readonly SemaphoreSlim _lock = new(1, 1);

public event Action? OnSettingsChanged;

public EncryptedSettingsService(IFileSystemService fileSystem, ISecureStorageService secureStorage)
{
_fileSystem = fileSystem;
_secureStorage = secureStorage;
_settingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".maui-sherpa",
SettingsFileName);
}

// Protected constructor for testing
protected EncryptedSettingsService()
{
_fileSystem = null!;
_secureStorage = null!;
_settingsPath = "";
}

public async Task<MauiSherpaSettings> GetSettingsAsync()
{
await _lock.WaitAsync();
try
{
if (_cachedSettings != null)
return _cachedSettings;

if (!await SettingsExistAsync())
{
_cachedSettings = new MauiSherpaSettings();
return _cachedSettings;
}

var encryptedData = await File.ReadAllBytesAsync(_settingsPath);
var masterKey = await GetOrCreateMasterKeyAsync();
var json = Decrypt(encryptedData, masterKey);
_cachedSettings = JsonSerializer.Deserialize<MauiSherpaSettings>(json) ?? new MauiSherpaSettings();
return _cachedSettings;
}
finally
{
_lock.Release();
}
}

public async Task SaveSettingsAsync(MauiSherpaSettings settings)
{
await _lock.WaitAsync();
try
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
Directory.CreateDirectory(directory);

// Create backup before saving
if (File.Exists(_settingsPath))
{
var backupPath = _settingsPath + ".bak";
File.Copy(_settingsPath, backupPath, overwrite: true);
}

var json = JsonSerializer.Serialize(settings with { LastModified = DateTime.UtcNow });
var masterKey = await GetOrCreateMasterKeyAsync();
var encrypted = Encrypt(json, masterKey);
await File.WriteAllBytesAsync(_settingsPath, encrypted);

_cachedSettings = settings;
OnSettingsChanged?.Invoke();
}
finally
{
_lock.Release();
}
}

public async Task UpdateSettingsAsync(Func<MauiSherpaSettings, MauiSherpaSettings> transform)
{
var current = await GetSettingsAsync();
var updated = transform(current);
await SaveSettingsAsync(updated);
}

public Task<bool> SettingsExistAsync()
{
return Task.FromResult(File.Exists(_settingsPath));
}

protected virtual async Task<byte[]> GetOrCreateMasterKeyAsync()
{
var keyBase64 = await _secureStorage.GetAsync(MasterKeyStorageKey);
if (!string.IsNullOrEmpty(keyBase64))
{
return Convert.FromBase64String(keyBase64);
}

// Generate new master key
var key = RandomNumberGenerator.GetBytes(KeySize);
await _secureStorage.SetAsync(MasterKeyStorageKey, Convert.ToBase64String(key));
return key;
}

private static byte[] Encrypt(string plaintext, byte[] key)
{
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
var ciphertext = new byte[plaintextBytes.Length];
var tag = new byte[TagSize];

using var aes = new AesGcm(key, TagSize);
aes.Encrypt(nonce, plaintextBytes, ciphertext, tag);

// Format: [nonce][tag][ciphertext]
var result = new byte[NonceSize + TagSize + ciphertext.Length];
Buffer.BlockCopy(nonce, 0, result, 0, NonceSize);
Buffer.BlockCopy(tag, 0, result, NonceSize, TagSize);
Buffer.BlockCopy(ciphertext, 0, result, NonceSize + TagSize, ciphertext.Length);
return result;
}

private static string Decrypt(byte[] encryptedData, byte[] key)
{
if (encryptedData.Length < NonceSize + TagSize)
throw new CryptographicException("Invalid encrypted data");

var nonce = new byte[NonceSize];
var tag = new byte[TagSize];
var ciphertext = new byte[encryptedData.Length - NonceSize - TagSize];

Buffer.BlockCopy(encryptedData, 0, nonce, 0, NonceSize);
Buffer.BlockCopy(encryptedData, NonceSize, tag, 0, TagSize);
Buffer.BlockCopy(encryptedData, NonceSize + TagSize, ciphertext, 0, ciphertext.Length);

var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, TagSize);
aes.Decrypt(nonce, ciphertext, tag, plaintext);

return Encoding.UTF8.GetString(plaintext);
}
}
Loading
Loading