From 6694289afd62fecef57c4f6d4e2c6ea107792050 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 1 May 2026 00:30:23 +0200 Subject: [PATCH 01/11] Enable complementation --- .../ViewModels/Authentication/KeyFileViewModel.cs | 2 +- .../SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml | 7 +------ .../Views/Credentials/CredentialsSelectionViewModel.cs | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/KeyFileViewModel.cs b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/KeyFileViewModel.cs index 948c60103..c50a87a38 100644 --- a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/KeyFileViewModel.cs +++ b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/KeyFileViewModel.cs @@ -32,7 +32,7 @@ public abstract class KeyFileViewModel : AuthenticationViewModel public override event EventHandler? StateChanged; /// - public sealed override bool CanComplement { get; } = false; + public sealed override bool CanComplement { get; } = true; /// public sealed override AuthenticationStage Availability { get; } = AuthenticationStage.Any; diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml b/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml index 3e97d670e..90c07f226 100644 --- a/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/CredentialsDialog.xaml @@ -166,12 +166,7 @@ x:Load="{x:Bind IsRemoving, Mode=OneWay, Converter={StaticResource BoolInvertConverter}}" Spacing="24"> - + Date: Fri, 1 May 2026 11:31:54 +0200 Subject: [PATCH 02/11] Update CliTestHost.cs --- .../CliTests/CliTestHost.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/SecureFolderFS.Tests/CliTests/CliTestHost.cs b/tests/SecureFolderFS.Tests/CliTests/CliTestHost.cs index fc8d7e255..3718fcaaf 100644 --- a/tests/SecureFolderFS.Tests/CliTests/CliTestHost.cs +++ b/tests/SecureFolderFS.Tests/CliTests/CliTestHost.cs @@ -99,19 +99,19 @@ private static IServiceProvider BuildServiceProvider(string settingsPath) return serviceProvider; } - private static CliApplication BuildApplication(IServiceProvider services) + private static CommandLineApplication BuildApplication(IServiceProvider services) { - return new CliApplicationBuilder() - .AddCommand() - .AddCommand() - .AddCommand() - .AddCommand() - .AddCommand() - .AddCommand() - .AddCommand() - .AddCommand() - .AddCommand() - .UseTypeActivator(type => ActivatorUtilities.CreateInstance(services, type)) + return new CommandLineApplicationBuilder() + .AddCommand(CredsAddCommand.Descriptor) + .AddCommand(CredsChangeCommand.Descriptor) + .AddCommand(CredsRemoveCommand.Descriptor) + .AddCommand(VaultCreateCommand.Descriptor) + .AddCommand(VaultInfoCommand.Descriptor) + .AddCommand(VaultMountCommand.Descriptor) + .AddCommand(VaultRunCommand.Descriptor) + .AddCommand(VaultShellCommand.Descriptor) + .AddCommand(VaultUnmountCommand.Descriptor) + .UseTypeInstantiator(type => ActivatorUtilities.CreateInstance(services, type)) .Build(); } } From f2fea420d826e5a2f0425c5d6104f10bfaaa980a Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 1 May 2026 15:44:12 +0200 Subject: [PATCH 03/11] Added Shares --- src/Core/SecureFolderFS.Core/Constants.cs | 1 + .../DataModels/VaultSharesDataModel.cs | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/Core/SecureFolderFS.Core/DataModels/VaultSharesDataModel.cs diff --git a/src/Core/SecureFolderFS.Core/Constants.cs b/src/Core/SecureFolderFS.Core/Constants.cs index 6b9b7f4ed..2289294c9 100644 --- a/src/Core/SecureFolderFS.Core/Constants.cs +++ b/src/Core/SecureFolderFS.Core/Constants.cs @@ -12,6 +12,7 @@ public static class Names public const string VAULT_CONTENT_FOLDERNAME = "content"; public const string VAULT_KEYSTORE_FILENAME = $"keystore{CONFIGURATION_EXTENSION}"; public const string VAULT_CONFIGURATION_FILENAME = $"sfconfig{CONFIGURATION_EXTENSION}"; + public const string VAULT_COMPLEMENTATION_FILENAME = $"sfcomplement{CONFIGURATION_EXTENSION}"; } public static class Authentication diff --git a/src/Core/SecureFolderFS.Core/DataModels/VaultSharesDataModel.cs b/src/Core/SecureFolderFS.Core/DataModels/VaultSharesDataModel.cs new file mode 100644 index 000000000..5642a26ae --- /dev/null +++ b/src/Core/SecureFolderFS.Core/DataModels/VaultSharesDataModel.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace SecureFolderFS.Core.DataModels +{ + [Serializable] + public sealed record class VaultSharesDataModel + { + public List? Shares { get; init; } + } + + [Serializable] + public sealed record class VaultShareDataModel + { + [JsonPropertyName("authOne")] + public string? AuthenticationMethodId { get; init; } + + [JsonPropertyName("nonce")] + public byte[]? Nonce { get; init; } + + [JsonPropertyName("c_complement")] + public byte[]? WrappedComplementSecret { get; init; } + + [JsonPropertyName("tag")] + public byte[]? Tag { get; init; } + } +} \ No newline at end of file From bbacd850a39a76312e19a594a5abe7aa5f50f601 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 1 May 2026 18:46:20 +0200 Subject: [PATCH 04/11] Begin working on complementation --- .../ModifyComplementationRoutine.cs | 413 ++++++++++++++++++ .../Routines/Operational/UnlockRoutine.cs | 58 ++- .../Routines/Operational/VaultRoutines.cs | 6 + .../VaultAccess/VaultParser.cs | 80 ++++ .../VaultAccess/VaultReader.cs | 13 + .../VaultAccess/VaultWriter.cs | 11 + .../AndroidVaultCredentialsService.cs | 2 +- .../IOSVaultCredentialsService.cs | 2 +- .../BaseVaultCredentialsService.cs | 9 + .../VaultManagerService.cs | 12 + .../SkiaVaultCredentialsService.cs | 2 +- .../MacOsVaultCredentialsService.cs | 2 +- .../WindowsVaultCredentialsService.cs | 2 +- .../VaultPreviewRootControl.xaml | 5 + .../UserControls/LoginOptions.xaml | 7 + .../UserControls/LoginOptions.xaml.cs | 39 ++ .../Views/Vault/VaultLoginPage.xaml | 5 + .../Services/IVaultManagerService.cs | 2 + .../ViewModels/Controls/LoginViewModel.cs | 51 ++- .../CredentialsConfirmationViewModel.cs | 67 ++- .../Models/ComplementationCredentials.cs | 13 + .../CliTests/CommandDiscoveryTests.cs | 3 +- .../VaultTests/CredentialTests.cs | 182 +++++++- 23 files changed, 974 insertions(+), 12 deletions(-) create mode 100644 src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs create mode 100644 src/Shared/SecureFolderFS.Shared/Models/ComplementationCredentials.cs diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs new file mode 100644 index 000000000..c17b27acb --- /dev/null +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs @@ -0,0 +1,413 @@ +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Core.Cryptography; +using SecureFolderFS.Core.DataModels; +using SecureFolderFS.Core.Models; +using SecureFolderFS.Core.Routines; +using SecureFolderFS.Core.VaultAccess; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Core.Routines.Operational +{ + public sealed class ModifyComplementationRoutine : IFinalizationRoutine, IContractRoutine, IOptionsRoutine + { + private const int ComplementSecretLength = 32; + + private readonly VaultReader _vaultReader; + private readonly VaultWriter _vaultWriter; + private KeyPair? _keyPair; + private V4VaultKeystoreDataModel? _existingKeystoreDataModel; + private V4VaultKeystoreDataModel? _keystoreDataModel; + private V4VaultConfigurationDataModel? _existingConfigDataModel; + private V4VaultConfigurationDataModel? _configDataModel; + private VaultSharesDataModel? _existingSharesDataModel; + private VaultSharesDataModel? _sharesDataModel; + private bool _writeShares; + + public ModifyComplementationRoutine(VaultReader vaultReader, VaultWriter vaultWriter) + { + _vaultReader = vaultReader; + _vaultWriter = vaultWriter; + } + + /// + public async Task InitAsync(CancellationToken cancellationToken = default) + { + _existingConfigDataModel = await _vaultReader.ReadV4ConfigurationAsync(cancellationToken); + _existingKeystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); + _existingSharesDataModel = await _vaultReader.ReadComplementationAsync(cancellationToken); + } + + /// + public void SetUnlockContract(IDisposable unlockContract) + { + if (unlockContract is not IWrapper securityWrapper) + throw new ArgumentException($"The {nameof(unlockContract)} is invalid."); + + _keyPair = securityWrapper.Inner.KeyPair; + } + + /// + public void SetOptions(VaultOptions vaultOptions) + { + _configDataModel = V4VaultConfigurationDataModel.V4FromVaultOptions(vaultOptions); + } + + public void SetCredentials(ComplementationCredentials credentials, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + ArgumentNullException.ThrowIfNull(_keyPair); + ArgumentNullException.ThrowIfNull(_existingConfigDataModel); + ArgumentNullException.ThrowIfNull(_existingKeystoreDataModel); + ArgumentNullException.ThrowIfNull(_configDataModel); + ArgumentNullException.ThrowIfNull(credentials); + + var oldAuthentication = AuthenticationMethod.FromString(_existingConfigDataModel.AuthenticationMethod); + var newAuthentication = AuthenticationMethod.FromString(_configDataModel.AuthenticationMethod); + var primaryChanged = !oldAuthentication.Methods.SequenceEqual(newAuthentication.Methods, StringComparer.Ordinal); + + if (string.IsNullOrWhiteSpace(oldAuthentication.Complementation) && + !string.IsNullOrWhiteSpace(newAuthentication.Complementation)) + { + AddComplementation(credentials, newAuthentication); + return; + } + + if (!string.IsNullOrWhiteSpace(oldAuthentication.Complementation) && + string.IsNullOrWhiteSpace(newAuthentication.Complementation)) + { + RemoveComplementation(credentials, oldAuthentication); + return; + } + + if (!string.IsNullOrWhiteSpace(oldAuthentication.Complementation) && + !string.IsNullOrWhiteSpace(newAuthentication.Complementation)) + { + if (primaryChanged || credentials.NewPrimaryCredential is not null) + ChangePrimaryAndPreserveComplementation(credentials, oldAuthentication, newAuthentication); + else if (!string.Equals(oldAuthentication.Complementation, newAuthentication.Complementation, StringComparison.Ordinal) || + credentials.NewComplementCredential is not null) + ReplaceComplementation(credentials, oldAuthentication, newAuthentication); + else + throw new InvalidOperationException("No complementation change was requested."); + + return; + } + + throw new InvalidOperationException("The requested authentication change does not involve complementation."); + } + + private void AddComplementation( + ComplementationCredentials credentials, + AuthenticationMethod newAuthentication) + { + var newComplementMethod = newAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing."); + var currentKey = ExportKey(credentials.CurrentCredential); + var newComplementKey = ExportKey(credentials.NewComplementCredential ?? throw new InvalidOperationException("New complement credentials are required.")); + byte[]? newPrimaryKey = null; + byte[]? softwareEntropy = null; + byte[]? complementSecret = null; + + try + { + newPrimaryKey = credentials.NewPrimaryCredential is null ? currentKey : ExportKey(credentials.NewPrimaryCredential); + softwareEntropy = DecryptSoftwareEntropy(currentKey); + complementSecret = DeriveComplementSecret(newPrimaryKey, GetPrimaryMethod(newAuthentication)); + + ReEncryptKeystore(complementSecret, softwareEntropy); + _sharesDataModel = CreateShares(VaultParser.V4WrapComplementSecret(complementSecret, newComplementKey, GetVaultId(), newComplementMethod)); + _writeShares = true; + } + finally + { + Zero(newPrimaryKey, currentKey); + Zero(complementSecret); + Zero(softwareEntropy); + Zero(newComplementKey); + Zero(currentKey); + } + + } + + private void ReplaceComplementation( + ComplementationCredentials credentials, + AuthenticationMethod oldAuthentication, + AuthenticationMethod newAuthentication) + { + var newComplementMethod = newAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing."); + var currentKey = ExportKey(credentials.CurrentCredential); + var newComplementKey = ExportKey(credentials.NewComplementCredential ?? throw new InvalidOperationException("New complement credentials are required.")); + byte[]? complementSecret = null; + byte[]? softwareEntropy = null; + + try + { + complementSecret = RecoverComplementSecret(currentKey, oldAuthentication, allowPrimary: true); + softwareEntropy = DecryptSoftwareEntropy(complementSecret); + + ReEncryptKeystore(complementSecret, softwareEntropy); + _sharesDataModel = CreateShares(VaultParser.V4WrapComplementSecret(complementSecret, newComplementKey, GetVaultId(), newComplementMethod)); + _writeShares = true; + } + finally + { + Zero(softwareEntropy); + Zero(complementSecret); + Zero(newComplementKey); + Zero(currentKey); + } + } + + private void RemoveComplementation(ComplementationCredentials credentials, AuthenticationMethod oldAuthentication) + { + var currentPrimaryKey = ExportKey(credentials.CurrentCredential); + byte[]? targetPasskey = null; + byte[]? complementSecret = null; + byte[]? softwareEntropy = null; + + try + { + targetPasskey = credentials.NewPrimaryCredential is null ? currentPrimaryKey : ExportKey(credentials.NewPrimaryCredential); + complementSecret = DeriveComplementSecret(currentPrimaryKey, GetPrimaryMethod(oldAuthentication)); + softwareEntropy = DecryptSoftwareEntropy(complementSecret); + + ReEncryptKeystore(targetPasskey, softwareEntropy); + _sharesDataModel = null; + _writeShares = true; + } + finally + { + Zero(softwareEntropy); + Zero(complementSecret); + Zero(targetPasskey, currentPrimaryKey); + Zero(currentPrimaryKey); + } + } + + private void ChangePrimaryAndPreserveComplementation( + ComplementationCredentials credentials, + AuthenticationMethod oldAuthentication, + AuthenticationMethod newAuthentication) + { + var oldComplementMethod = oldAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing."); + var newComplementMethod = newAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing."); + var currentComplementKey = ExportKey(credentials.CurrentCredential); + var newPrimaryKey = ExportKey(credentials.NewPrimaryCredential ?? throw new InvalidOperationException("New primary credentials are required.")); + byte[]? newComplementKey = null; + byte[]? oldComplementSecret = null; + byte[]? newComplementSecret = null; + byte[]? softwareEntropy = null; + + try + { + oldComplementSecret = RecoverComplementSecretFromShare(currentComplementKey, oldComplementMethod); + softwareEntropy = DecryptSoftwareEntropy(oldComplementSecret); + newComplementSecret = DeriveComplementSecret(newPrimaryKey, GetPrimaryMethod(newAuthentication)); + + newComplementKey = string.Equals(oldComplementMethod, newComplementMethod, StringComparison.Ordinal) + ? currentComplementKey + : ExportKey(credentials.NewComplementCredential ?? throw new InvalidOperationException("New complement credentials are required.")); + + ReEncryptKeystore(newComplementSecret, softwareEntropy); + _sharesDataModel = CreateShares(VaultParser.V4WrapComplementSecret(newComplementSecret, newComplementKey, GetVaultId(), newComplementMethod)); + _writeShares = true; + } + finally + { + Zero(softwareEntropy); + Zero(newComplementSecret); + Zero(oldComplementSecret); + Zero(newComplementKey, currentComplementKey); + Zero(newPrimaryKey); + Zero(currentComplementKey); + } + } + + private byte[] RecoverComplementSecret(byte[] currentKey, AuthenticationMethod oldAuthentication, bool allowPrimary) + { + CryptographicException? lastException = null; + + if (allowPrimary) + { + byte[]? complementSecret = null; + byte[]? softwareEntropy = null; + try + { + complementSecret = DeriveComplementSecret(currentKey, GetPrimaryMethod(oldAuthentication)); + softwareEntropy = DecryptSoftwareEntropy(complementSecret); + return complementSecret; + } + catch (CryptographicException ex) + { + lastException = ex; + Zero(complementSecret); + } + finally + { + Zero(softwareEntropy); + } + } + + if (!string.IsNullOrWhiteSpace(oldAuthentication.Complementation)) + return RecoverComplementSecretFromShare(currentKey, oldAuthentication.Complementation, lastException); + + throw lastException ?? new CryptographicException("The complement secret could not be recovered."); + } + + private byte[] RecoverComplementSecretFromShare(byte[] currentKey, string complementMethod, CryptographicException? fallbackException = null) + { + var share = GetShare(complementMethod); + byte[]? complementSecret = null; + byte[]? softwareEntropy = null; + + try + { + complementSecret = VaultParser.V4UnwrapComplementSecret(currentKey, GetVaultId(), share); + softwareEntropy = DecryptSoftwareEntropy(complementSecret); + return complementSecret; + } + catch (CryptographicException) when (fallbackException is not null) + { + Zero(complementSecret); + throw fallbackException; + } + catch + { + Zero(complementSecret); + throw; + } + finally + { + Zero(softwareEntropy); + } + } + + private byte[] DeriveComplementSecret(byte[] passkey, string authenticationMethodId) + { + var complementSecret = new byte[ComplementSecretLength]; + try + { + VaultParser.V4DeriveComplementKey(passkey, GetVaultId(), authenticationMethodId, complementSecret); + return complementSecret; + } + catch + { + Zero(complementSecret); + throw; + } + } + + private byte[] DecryptSoftwareEntropy(byte[] passkey) + { + ArgumentNullException.ThrowIfNull(_existingKeystoreDataModel); + + var softwareEntropy = new byte[ComplementSecretLength]; + try + { + VaultParser.V4DecryptSoftwareEntropy(passkey, _existingKeystoreDataModel, softwareEntropy); + return softwareEntropy; + } + catch + { + Zero(softwareEntropy); + throw; + } + } + + private void ReEncryptKeystore(byte[] passkey, byte[] softwareEntropy) + { + ArgumentNullException.ThrowIfNull(_keyPair); + + var salt = new byte[Cryptography.Constants.KeyTraits.SALT_LENGTH]; + RandomNumberGenerator.Fill(salt); + + _keystoreDataModel = _keyPair.UseKeys((dekKey, macKey) => + VaultParser.V4ReEncryptKeystore(passkey, dekKey, macKey, salt, softwareEntropy)); + } + + private VaultShareDataModel GetShare(string authenticationMethodId) + { + return _existingSharesDataModel?.Shares?.FirstOrDefault(x => + string.Equals(x.AuthenticationMethodId, authenticationMethodId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Complementation share '{authenticationMethodId}' was not found."); + } + + private string GetVaultId() + { + ArgumentNullException.ThrowIfNull(_existingConfigDataModel); + return _existingConfigDataModel.Uid; + } + + private static string GetPrimaryMethod(AuthenticationMethod authenticationMethod) + { + return authenticationMethod.Methods.FirstOrDefault() ?? throw new InvalidOperationException("Primary authentication is missing."); + } + + private static VaultSharesDataModel CreateShares(VaultShareDataModel shareDataModel) + { + return new() + { + Shares = [ shareDataModel ] + }; + } + + private static byte[] ExportKey(IKeyUsage key) + { + var exported = new byte[key.Length]; + try + { + key.UseKey(source => source.CopyTo(exported)); + return exported; + } + catch + { + Zero(exported); + throw; + } + } + + private static void Zero(byte[]? key) + { + if (key is not null) + CryptographicOperations.ZeroMemory(key); + } + + private static void Zero(byte[]? key, byte[] sameAs) + { + if (key is not null && !ReferenceEquals(key, sameAs)) + CryptographicOperations.ZeroMemory(key); + } + + /// + public async Task FinalizeAsync(CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(_keyPair); + ArgumentNullException.ThrowIfNull(_keystoreDataModel); + ArgumentNullException.ThrowIfNull(_configDataModel); + + _keyPair.MacKey.UseKey(macKey => + { + VaultParser.V4CalculateConfigMac(_configDataModel, macKey, _configDataModel.PayloadMac); + }); + + await _vaultWriter.WriteKeystoreAsync(_keystoreDataModel, cancellationToken); + await _vaultWriter.WriteV4ConfigurationAsync(_configDataModel, cancellationToken); + + if (_writeShares) + await _vaultWriter.WriteComplementationAsync(_sharesDataModel, cancellationToken); + + using (_keyPair) + return new SecurityWrapper(_keyPair.CreateCopy(), _configDataModel); + } + + /// + public void Dispose() + { + _keyPair?.Dispose(); + } + } +} diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs index 1de4732a1..aa7e6b961 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs @@ -1,4 +1,6 @@ using System; +using System.Linq; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using SecureFolderFS.Core.Cryptography; @@ -7,6 +9,7 @@ using SecureFolderFS.Core.Validators; using SecureFolderFS.Core.VaultAccess; using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; using SecureFolderFS.Shared.SecureStore; namespace SecureFolderFS.Core.Routines.Operational @@ -17,6 +20,7 @@ internal sealed class UnlockRoutine : ICredentialsRoutine private readonly VaultReader _vaultReader; private V4VaultKeystoreDataModel? _keystoreDataModel; private V4VaultConfigurationDataModel? _configDataModel; + private VaultSharesDataModel? _sharesDataModel; private SecureKey? _dekKey; private SecureKey? _macKey; @@ -30,6 +34,7 @@ public async Task InitAsync(CancellationToken cancellationToken) { _configDataModel = await _vaultReader.ReadV4ConfigurationAsync(cancellationToken); _keystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); + _sharesDataModel = await _vaultReader.ReadComplementationAsync(cancellationToken); } /// @@ -38,11 +43,62 @@ public void SetCredentials(IKeyUsage passkey) ArgumentNullException.ThrowIfNull(_configDataModel); ArgumentNullException.ThrowIfNull(_keystoreDataModel); - var derived = passkey.UseKey(key => VaultParser.V4DeriveKeystore(key, _keystoreDataModel)); + var authenticationMethod = AuthenticationMethod.FromString(_configDataModel.AuthenticationMethod); + var derived = string.IsNullOrWhiteSpace(authenticationMethod.Complementation) + ? passkey.UseKey(key => VaultParser.V4DeriveKeystore(key, _keystoreDataModel)) + : DeriveComplementedKeystore(passkey, authenticationMethod); + _dekKey = SecureKey.TakeOwnership(derived.dekKey); _macKey = SecureKey.TakeOwnership(derived.macKey); } + private (byte[] dekKey, byte[] macKey) DeriveComplementedKeystore(IKeyUsage passkey, AuthenticationMethod authenticationMethod) + { + ArgumentNullException.ThrowIfNull(_configDataModel); + ArgumentNullException.ThrowIfNull(_keystoreDataModel); + + CryptographicException? lastException = null; + var primaryMethodId = authenticationMethod.Methods.FirstOrDefault() ?? throw new InvalidOperationException("Primary authentication is missing."); + + try + { + return passkey.UseKey(key => + { + Span complementSecret = stackalloc byte[32]; + VaultParser.V4DeriveComplementKey(key, _configDataModel.Uid, primaryMethodId, complementSecret); + return VaultParser.V4DeriveKeystore(complementSecret, _keystoreDataModel); + }); + } + catch (CryptographicException ex) + { + lastException = ex; + } + + foreach (var share in _sharesDataModel?.Shares ?? []) + { + if (!string.Equals(share.AuthenticationMethodId, authenticationMethod.Complementation, StringComparison.Ordinal)) + continue; + + byte[]? complementSecret = null; + try + { + complementSecret = passkey.UseKey(key => VaultParser.V4UnwrapComplementSecret(key, _configDataModel.Uid, share)); + return VaultParser.V4DeriveKeystore(complementSecret, _keystoreDataModel); + } + catch (CryptographicException ex) + { + lastException = ex; + } + finally + { + if (complementSecret is not null) + CryptographicOperations.ZeroMemory(complementSecret); + } + } + + throw lastException ?? new CryptographicException("The complemented credentials could not unlock this vault."); + } + /// public async Task FinalizeAsync(CancellationToken cancellationToken) { diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs index 07f10296e..448a75927 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs @@ -51,6 +51,12 @@ public IModifyCredentialsRoutine ModifyCredentials() return new ModifyCredentialsRoutine(VaultReader, VaultWriter); } + public ModifyComplementationRoutine ModifyComplementation() + { + CheckVaultValidation(); + return new ModifyComplementationRoutine(VaultReader, VaultWriter); + } + private void CheckVaultValidation() { if (!_validationResult.Successful) diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs index 28bfc518d..af0c7ec90 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs @@ -265,6 +265,86 @@ public static void V4DecryptSoftwareEntropy( softwareEntropy); } + public static void V4DeriveComplementKey( + ReadOnlySpan passkey, + string vaultId, + string authenticationMethodId, + Span complementKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(vaultId); + ArgumentException.ThrowIfNullOrWhiteSpace(authenticationMethodId); + + var salt = Encoding.UTF8.GetBytes(vaultId); + var info = Encoding.UTF8.GetBytes(authenticationMethodId); + + HKDF.DeriveKey( + HashAlgorithmName.SHA256, + passkey, + complementKey, + salt, + info); + } + + public static VaultShareDataModel V4WrapComplementSecret( + ReadOnlySpan complementSecret, + ReadOnlySpan wrappingKeyMaterial, + string vaultId, + string authenticationMethodId) + { + Span complementWrapKey = stackalloc byte[32]; + try + { + V4DeriveComplementKey(wrappingKeyMaterial, vaultId, authenticationMethodId, complementWrapKey); + + var nonce = new byte[12]; + var tag = new byte[16]; + var wrapped = new byte[complementSecret.Length]; + RandomNumberGenerator.Fill(nonce); + + using (var aes = new AesGcm(complementWrapKey, 16)) + aes.Encrypt(nonce, complementSecret, wrapped, tag); + + return new() + { + AuthenticationMethodId = authenticationMethodId, + Nonce = nonce, + WrappedComplementSecret = wrapped, + Tag = tag + }; + } + finally + { + CryptographicOperations.ZeroMemory(complementWrapKey); + } + } + + public static byte[] V4UnwrapComplementSecret( + ReadOnlySpan wrappingKeyMaterial, + string vaultId, + VaultShareDataModel shareDataModel) + { + ArgumentNullException.ThrowIfNull(shareDataModel.AuthenticationMethodId); + ArgumentNullException.ThrowIfNull(shareDataModel.Nonce); + ArgumentNullException.ThrowIfNull(shareDataModel.WrappedComplementSecret); + ArgumentNullException.ThrowIfNull(shareDataModel.Tag); + + Span complementWrapKey = stackalloc byte[32]; + try + { + V4DeriveComplementKey(wrappingKeyMaterial, vaultId, shareDataModel.AuthenticationMethodId, complementWrapKey); + + var complementSecret = new byte[shareDataModel.WrappedComplementSecret.Length]; + using (var aes = new AesGcm(complementWrapKey, 16)) + aes.Decrypt(shareDataModel.Nonce, shareDataModel.WrappedComplementSecret, shareDataModel.Tag, complementSecret); + + return complementSecret; + } + finally + { + CryptographicOperations.ZeroMemory(complementWrapKey); + } + } + /// /// Shared implementation for both and . /// Encrypts the provided entropy under the passkey and wraps DEK/MAC under the augmented KEK. diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs index 304ed0faa..4615172ee 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs @@ -53,6 +53,19 @@ public async Task ReadV4ConfigurationAsync(Cancel return await ReadDataAsync(configFile, _serializer, cancellationToken); } + public async Task ReadComplementationAsync(CancellationToken cancellationToken) + { + try + { + var complementFile = await _vaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_COMPLEMENTATION_FILENAME, cancellationToken); + return await ReadDataAsync(complementFile, _serializer, cancellationToken); + } + catch (Exception) + { + return null; + } + } + public async Task ReadVersionAsync(CancellationToken cancellationToken) { // Get configuration file diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs index d30715db7..09a2a9127 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs @@ -57,6 +57,17 @@ public async Task WriteV4ConfigurationAsync(V4VaultConfigurationDataModel? confi await WriteDataAsync(configFile, configDataModel, cancellationToken); } + public async Task WriteComplementationAsync(VaultSharesDataModel? sharesDataModel, CancellationToken cancellationToken) + { + var complementFile = _vaultFolder switch + { + IModifiableFolder modifiableFolder when sharesDataModel is not null => await modifiableFolder.CreateFileAsync(Constants.Vault.Names.VAULT_COMPLEMENTATION_FILENAME, true, cancellationToken), + _ => await _vaultFolder.GetFirstByNameAsync(Constants.Vault.Names.VAULT_COMPLEMENTATION_FILENAME, cancellationToken) as IFile + }; + + await WriteDataAsync(complementFile, sharesDataModel, cancellationToken); + } + public async Task WriteAuthenticationAsync(string fileName, TCapability? authDataModel, CancellationToken cancellationToken) where TCapability : VaultCapabilityDataModel { diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs index 002316b66..2915350ac 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs @@ -36,7 +36,7 @@ protected override async IAsyncEnumerable GetLoginAsync string vaultId, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - foreach (var item in unlockProcedure.Methods) + foreach (var item in EnumerateLoginMethods(unlockProcedure)) { yield return item switch { diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs index 883f2ce4d..d651c1fc9 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs @@ -43,7 +43,7 @@ protected override async IAsyncEnumerable GetLoginAsync [EnumeratorCancellation] CancellationToken cancellationToken = default) { await Task.CompletedTask; - foreach (var item in unlockProcedure.Methods) + foreach (var item in EnumerateLoginMethods(unlockProcedure)) { yield return item switch { diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/BaseVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/BaseVaultCredentialsService.cs index 975126659..4190d9ed0 100644 --- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/BaseVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/BaseVaultCredentialsService.cs @@ -85,5 +85,14 @@ public virtual async IAsyncEnumerable GetLoginAsync(IFo /// public abstract IAsyncEnumerable GetCreationAsync(IFolder vaultFolder, string vaultId, CancellationToken cancellationToken = default); + + protected static IEnumerable EnumerateLoginMethods(AuthenticationMethod unlockProcedure) + { + foreach (var item in unlockProcedure.Methods) + yield return item; + + if (!string.IsNullOrWhiteSpace(unlockProcedure.Complementation)) + yield return unlockProcedure.Complementation; + } } } diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultManagerService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultManagerService.cs index 0322ef188..9d35efe10 100644 --- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultManagerService.cs +++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultManagerService.cs @@ -55,6 +55,18 @@ public virtual async Task RecoverAsync(IFolder vaultFolder, string return await recoveryRoutine.FinalizeAsync(cancellationToken); } + + /// + public virtual async Task ModifyComplementationAsync(IFolder vaultFolder, IDisposable unlockContract, ComplementationCredentials credentials, VaultOptions vaultOptions, CancellationToken cancellationToken = default) + { + using var complementationRoutine = (await VaultRoutines.CreateRoutinesAsync(vaultFolder, StreamSerializer.Instance, cancellationToken)).ModifyComplementation(); + await complementationRoutine.InitAsync(cancellationToken); + complementationRoutine.SetUnlockContract(unlockContract); + complementationRoutine.SetOptions(vaultOptions); + complementationRoutine.SetCredentials(credentials, cancellationToken); + + using var result = await complementationRoutine.FinalizeAsync(cancellationToken); + } /// public virtual async Task ModifyAuthenticationAsync(IFolder vaultFolder, IDisposable unlockContract, IKeyUsage newPasskey, VaultOptions vaultOptions, CancellationToken cancellationToken = default) diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs index ad14c8323..0487ed7c7 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs @@ -58,7 +58,7 @@ protected override async IAsyncEnumerable GetLoginAsync [EnumeratorCancellation] CancellationToken cancellationToken = default) { await Task.CompletedTask; - foreach (var item in unlockProcedure.Methods) + foreach (var item in EnumerateLoginMethods(unlockProcedure)) { yield return item switch { diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsVaultCredentialsService.cs index 2eef69bc2..07a254338 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/MacCatalyst/ServiceImplementation/MacOsVaultCredentialsService.cs @@ -28,7 +28,7 @@ public override async IAsyncEnumerable GetLoginAsync(IF var config = await vaultReader.ReadConfigurationAsync(cancellationToken); var authenticationMethod = AuthenticationMethod.FromString(config.AuthenticationMethod); - foreach (var item in authenticationMethod.Methods) + foreach (var item in EnumerateLoginMethods(authenticationMethod)) { yield return item switch { diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ServiceImplementation/WindowsVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ServiceImplementation/WindowsVaultCredentialsService.cs index cbb1cac41..fd383a5fb 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ServiceImplementation/WindowsVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ServiceImplementation/WindowsVaultCredentialsService.cs @@ -53,7 +53,7 @@ protected override async IAsyncEnumerable GetLoginAsync AuthenticationMethod unlockProcedure, string vaultId, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - foreach (var item in unlockProcedure.Methods) + foreach (var item in EnumerateLoginMethods(unlockProcedure)) { yield return item switch { diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml index 618b2bd9a..a94b99f87 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml @@ -51,10 +51,15 @@ diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml index f4e0f0f53..d9cf4d40d 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/LoginOptions.xaml @@ -15,6 +15,13 @@ + (bool)GetValue(IsAlternativeLoginProperty); + set => SetValue(IsAlternativeLoginProperty, value); + } + public static readonly DependencyProperty IsAlternativeLoginProperty = + DependencyProperty.Register(nameof(IsAlternativeLogin), typeof(bool), typeof(LoginOptions), new PropertyMetadata(false)); + + public IEnumerable? AuthenticationOptions + { + get => (IEnumerable?)GetValue(AuthenticationOptionsProperty); + set => SetValue(AuthenticationOptionsProperty, value); + } + public static readonly DependencyProperty AuthenticationOptionsProperty = + DependencyProperty.Register(nameof(AuthenticationOptions), typeof(IEnumerable), typeof(LoginOptions), new PropertyMetadata(null)); + + public object? SelectedAuthenticationOption + { + get => GetValue(SelectedAuthenticationOptionProperty); + set => SetValue(SelectedAuthenticationOptionProperty, value); + } + public static readonly DependencyProperty SelectedAuthenticationOptionProperty = + DependencyProperty.Register(nameof(SelectedAuthenticationOption), typeof(object), typeof(LoginOptions), new PropertyMetadata(null)); + + public ICommand? SelectAuthenticationOptionCommand + { + get => (ICommand?)GetValue(SelectAuthenticationOptionCommandProperty); + set => SetValue(SelectAuthenticationOptionCommandProperty, value); + } + public static readonly DependencyProperty SelectAuthenticationOptionCommandProperty = + DependencyProperty.Register(nameof(SelectAuthenticationOptionCommand), typeof(ICommand), typeof(LoginOptions), new PropertyMetadata(null)); + + private void AuthenticationOptions_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is ComboBox { SelectedItem: { } selectedItem } && SelectAuthenticationOptionCommand?.CanExecute(selectedItem) == true) + SelectAuthenticationOptionCommand.Execute(selectedItem); + } + public ICommand? DiscardSavedCredentialsCommand { get => (ICommand?)GetValue(DiscardSavedCredentialsCommandProperty); diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultLoginPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultLoginPage.xaml index 1b3b976fe..2478ab033 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultLoginPage.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultLoginPage.xaml @@ -50,10 +50,15 @@ diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs index 97e0e6ec8..56dffa3e4 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs @@ -48,6 +48,8 @@ public interface IVaultManagerService // TODO: Consider using IVaultUnlockingModel //Task GetUnlockingModelAsync(IFolder vaultFolder, CancellationToken cancellationToken = default); + Task ModifyComplementationAsync(IFolder vaultFolder, IDisposable unlockContract, ComplementationCredentials credentials, VaultOptions vaultOptions, CancellationToken cancellationToken = default); + /// /// Modifies the configured authentication for the specified . /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs index 3802b2a5a..a737ea7b6 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; using System.Threading; @@ -38,9 +39,12 @@ public sealed partial class LoginViewModel : ObservableObject, IViewable, IAsync [ObservableProperty] private string? _Title; [ObservableProperty] private bool _CanRecover; [ObservableProperty] private bool _IsLoginSequence; + [ObservableProperty] private bool _IsAlternativeLogin; [ObservableProperty] private bool _AreCredentialsSaved; [ObservableProperty] private bool _ShouldSaveCredentials; [ObservableProperty] private ICommand? _ProvideCredentialsCommand; + [ObservableProperty] private AuthenticationViewModel? _SelectedAuthenticationOption; + [ObservableProperty] private ObservableCollection _AuthenticationOptions; [ObservableProperty] private ReportableViewModel? _CurrentViewModel; /// @@ -54,6 +58,7 @@ public LoginViewModel(IFolder vaultFolder, LoginViewType loginViewMode, KeySeque _vaultFolder = vaultFolder; _loginViewMode = loginViewMode; _keySequence = keySequence ?? new(); + _AuthenticationOptions = new(); _vaultWatcherModel = new VaultWatcherModel(_vaultFolder); _vaultWatcherModel.StateChanged += VaultWatcherModel_StateChanged; } @@ -74,6 +79,10 @@ public async Task InitAsync(CancellationToken cancellationToken = default) // Dispose previous state, if any _keySequence.Dispose(); _loginSequence?.Dispose(); + AuthenticationOptions.DisposeAll(); + AuthenticationOptions.Clear(); + SelectedAuthenticationOption = null; + IsAlternativeLogin = false; var validationResult = await VaultService.VaultValidator.TryValidateAsync(_vaultFolder, cancellationToken); if (validationResult.Successful) @@ -93,11 +102,25 @@ public async Task InitAsync(CancellationToken cancellationToken = default) } // Get the authentication method enumerator for this vault + _vaultOptions = await VaultService.GetVaultOptionsAsync(_vaultFolder, cancellationToken); var loginItems = await VaultCredentialsService.GetLoginAsync(_vaultFolder, cancellationToken).ToArrayAsyncImpl(cancellationToken); - _loginSequence = new(loginItems); - IsLoginSequence = _loginSequence.Count > 1; + if (!string.IsNullOrWhiteSpace(_vaultOptions.UnlockProcedure.Complementation)) + { + foreach (var item in loginItems) + AuthenticationOptions.Add(item); + + IsAlternativeLogin = AuthenticationOptions.Count > 1; + IsLoginSequence = false; + SelectedAuthenticationOption = AuthenticationOptions.FirstOrDefault(); + CurrentViewModel = SelectedAuthenticationOption is not null + ? SelectedAuthenticationOption + : new ErrorViewModel("No authentication methods available."); + return; + } // Set up the first authentication method + _loginSequence = new(loginItems); + IsLoginSequence = _loginSequence.Count > 1; var result = ProceedAuthentication(); if (!result.Successful) CurrentViewModel = new ErrorViewModel(result); @@ -191,12 +214,27 @@ private void RestartLoginProcess() { // Dispose built key sequence _keySequence.Dispose(); + + if (IsAlternativeLogin) + return; + _loginSequence?.Reset(); var result = ProceedAuthentication(); if (!result.Successful) CurrentViewModel = new ErrorViewModel(result); } + [RelayCommand] + private void SelectAuthenticationOption(AuthenticationViewModel? authenticationViewModel) + { + if (!IsAlternativeLogin || authenticationViewModel is null) + return; + + _keySequence.Dispose(); + SelectedAuthenticationOption = authenticationViewModel; + CurrentViewModel = authenticationViewModel; + } + private async Task TryUnlockAsync(CancellationToken cancellationToken = default) { try @@ -288,6 +326,14 @@ private async void CurrentViewModel_CredentialsProvided(object? sender, Credenti // Add authentication _keySequence.Add(e.Authentication); + if (IsAlternativeLogin) + { + if (!await TryUnlockAsync()) + _keySequence.Dispose(); + + return; + } + var result = ProceedAuthentication(); if (!result.Successful && CurrentViewModel is not ErrorViewModel) { @@ -341,6 +387,7 @@ public void Dispose() authenticationViewModel.CredentialsProvided -= CurrentViewModel_CredentialsProvided; _keySequence.Dispose(); + AuthenticationOptions.DisposeAll(); _loginSequence?.Dispose(); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs index 1d345b792..fadc3b17c 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs @@ -109,7 +109,12 @@ private async Task ChangeCredentialsAsync(IKeyUsage key, VaultOptions configured }; if (OldPasskey is not null) - await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, UnlockContract, OldPasskey, key, updatedOptions, cancellationToken); + { + if (RequiresComplementationRoutine(configuredOptions.UnlockProcedure, unlockProcedure)) + await VaultManagerService.ModifyComplementationAsync(_vaultFolder, UnlockContract, CreateComplementationCredentials(key, configuredOptions.UnlockProcedure, unlockProcedure), updatedOptions, cancellationToken); + else + await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, UnlockContract, OldPasskey, key, updatedOptions, cancellationToken); + } else await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, UnlockContract, key, updatedOptions, cancellationToken); @@ -125,6 +130,66 @@ private async Task ChangeCredentialsAsync(IKeyUsage key, VaultOptions configured await ConfiguredViewModel.RevokeAsync(configuredOptions.VaultId, cancellationToken); } + private ComplementationCredentials CreateComplementationCredentials(IKeyUsage key, AuthenticationMethod configuredProcedure, AuthenticationMethod updatedProcedure) + { + ArgumentNullException.ThrowIfNull(OldPasskey); + + var currentCredential = GetCredentialAt(OldPasskey, 0) ?? OldPasskey; + var oldComplementation = configuredProcedure.Complementation; + var newComplementation = updatedProcedure.Complementation; + var primaryChanged = !configuredProcedure.Methods.SequenceEqual(updatedProcedure.Methods); + + if (string.IsNullOrWhiteSpace(oldComplementation) && !string.IsNullOrWhiteSpace(newComplementation)) + { + return new() + { + CurrentCredential = currentCredential, + NewComplementCredential = GetCredentialAt(key, 1) ?? key + }; + } + + if (!string.IsNullOrWhiteSpace(oldComplementation) && string.IsNullOrWhiteSpace(newComplementation)) + { + return new() + { + CurrentCredential = currentCredential, + NewPrimaryCredential = updatedProcedure.Methods.Length > 1 ? key : null + }; + } + + if (!string.IsNullOrWhiteSpace(oldComplementation) && !string.IsNullOrWhiteSpace(newComplementation)) + { + var updatePrimaryCredential = primaryChanged || _authenticationStage == AuthenticationStage.FirstStageOnly; + var updateComplementCredential = !string.Equals(oldComplementation, newComplementation, StringComparison.Ordinal) || + _authenticationStage == AuthenticationStage.ProceedingStageOnly; + + return new() + { + CurrentCredential = currentCredential, + NewPrimaryCredential = updatePrimaryCredential ? GetCredentialAt(key, 0) ?? key : null, + NewComplementCredential = updateComplementCredential ? GetCredentialAt(key, 1) ?? key : null + }; + } + + throw new InvalidOperationException("The requested authentication change does not involve complementation."); + } + + private static bool RequiresComplementationRoutine(AuthenticationMethod configuredProcedure, AuthenticationMethod updatedProcedure) + { + var wasComplemented = !string.IsNullOrWhiteSpace(configuredProcedure.Complementation); + var willBeComplemented = !string.IsNullOrWhiteSpace(updatedProcedure.Complementation); + var complementationChanged = !string.Equals(configuredProcedure.Complementation, updatedProcedure.Complementation, StringComparison.Ordinal); + + return complementationChanged || wasComplemented || willBeComplemented; + } + + private static IKeyUsage? GetCredentialAt(IKeyUsage key, int index) + { + return key is KeySequence sequence + ? sequence.Keys.ElementAtOrDefault(index) + : index == 0 ? key : null; + } + private void RegisterViewModel_CredentialsProvided(object? sender, CredentialsProvidedEventArgs e) { _credentialsTcs.TrySetResult(e.Authentication); diff --git a/src/Shared/SecureFolderFS.Shared/Models/ComplementationCredentials.cs b/src/Shared/SecureFolderFS.Shared/Models/ComplementationCredentials.cs new file mode 100644 index 000000000..0ded4196b --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Models/ComplementationCredentials.cs @@ -0,0 +1,13 @@ +using SecureFolderFS.Shared.ComponentModel; + +namespace SecureFolderFS.Shared.Models +{ + public sealed record class ComplementationCredentials + { + public required IKeyUsage CurrentCredential { get; init; } + + public IKeyUsage? NewPrimaryCredential { get; init; } + + public IKeyUsage? NewComplementCredential { get; init; } + } +} diff --git a/tests/SecureFolderFS.Tests/CliTests/CommandDiscoveryTests.cs b/tests/SecureFolderFS.Tests/CliTests/CommandDiscoveryTests.cs index 192e4f3dd..bee5ddc7b 100644 --- a/tests/SecureFolderFS.Tests/CliTests/CommandDiscoveryTests.cs +++ b/tests/SecureFolderFS.Tests/CliTests/CommandDiscoveryTests.cs @@ -1,5 +1,5 @@ using CliFx; -using CliFx.Attributes; +using CliFx.Binding; using FluentAssertions; using NUnit.Framework; using SecureFolderFS.Cli.Commands; @@ -41,4 +41,3 @@ public void CliAssembly_ExposesExpectedCommandTypes() ]); } } - diff --git a/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs b/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs index a62e1603a..d32915753 100644 --- a/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs +++ b/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs @@ -2,11 +2,13 @@ using NUnit.Framework; using OwlCore.Storage; using OwlCore.Storage.Memory; +using SecureFolderFS.Core.VaultAccess; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; +using SecureFolderFS.Shared.SecureStore; using SecureFolderFS.UI.ViewModels.Authentication; using static SecureFolderFS.Core.Constants.Vault.Authentication; @@ -195,6 +197,156 @@ public async Task ModifyAuthentication_InvalidUnlockContract_ThrowsArgumentExcep } } + [Test] + public async Task ModifyComplementation_AddKeyFile_AllowsPasswordOrKeyFile() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultService = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + var passwordProcedure = new AuthenticationMethod([AUTH_PASSWORD], null); + var complementedProcedure = new AuthenticationMethod([AUTH_PASSWORD], AUTH_KEYFILE); + + using var password = await GetPasswordCreationCredentialAsync("Password#1"); + using var _ = await manager.CreateAsync(vaultFolder, password, CreateOptions(passwordProcedure, vaultId)); + + using var unlockPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var unlockContract = await manager.UnlockAsync(vaultFolder, unlockPasskey); + using var keyFile = await GetKeyFileCreationCredentialAsync(vaultId); + + // Act + await manager.ModifyComplementationAsync(vaultFolder, unlockContract, new() + { + CurrentCredential = unlockPasskey, + NewComplementCredential = keyFile + }, CreateOptions(complementedProcedure, vaultId)); + + // Assert + using var passwordOnlyPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var keyFileOnlyPasskey = keyFile.CreateCopy(); + using var wrongPasswordPasskey = await GetPasswordLoginCredentialAsync("WrongPassword"); + using var chainedPasskey = new KeySequence(); + chainedPasskey.Add(await GetPasswordLoginCredentialAsync("Password#1")); + chainedPasskey.Add(keyFile.CreateCopy()); + + (await CanUnlockAsync(manager, vaultFolder, passwordOnlyPasskey)).Should().BeTrue(); + (await CanUnlockAsync(manager, vaultFolder, keyFileOnlyPasskey)).Should().BeTrue(); + (await CanUnlockAsync(manager, vaultFolder, chainedPasskey)).Should().BeFalse(); + (await CanUnlockAsync(manager, vaultFolder, wrongPasswordPasskey)).Should().BeFalse(); + + var configuredOptions = await vaultService.GetVaultOptionsAsync(vaultFolder); + var shares = await new VaultReader(vaultFolder, StreamSerializer.Instance).ReadComplementationAsync(CancellationToken.None); + + configuredOptions.UnlockProcedure.Should().BeEquivalentTo(complementedProcedure); + shares.Should().NotBeNull(); + shares!.Shares.Should().ContainSingle(x => x.AuthenticationMethodId == AUTH_KEYFILE); + } + + [Test] + public async Task ModifyComplementation_ReplaceComplement_UsesNewComplementAndRejectsOld() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultService = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + var biometricProcedure = new AuthenticationMethod([AUTH_PASSWORD], AUTH_APPLE_BIOMETRIC); + using var oldKeyFile = await CreatePasswordVaultWithKeyFileComplementAsync(manager, vaultFolder, "Password#1", vaultId); + + using var oldComplementUnlock = oldKeyFile.CreateCopy(); + using var unlockContract = await manager.UnlockAsync(vaultFolder, oldComplementUnlock); + using var newComplement = SecureKey.CreateSecureRandom(32); + + // Act + await manager.ModifyComplementationAsync(vaultFolder, unlockContract, new() + { + CurrentCredential = oldKeyFile, + NewComplementCredential = newComplement + }, CreateOptions(biometricProcedure, vaultId)); + + // Assert + using var passwordOnlyPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var oldComplementPasskey = oldKeyFile.CreateCopy(); + using var newComplementPasskey = newComplement.CreateCopy(); + + (await CanUnlockAsync(manager, vaultFolder, passwordOnlyPasskey)).Should().BeTrue(); + (await CanUnlockAsync(manager, vaultFolder, oldComplementPasskey)).Should().BeFalse(); + (await CanUnlockAsync(manager, vaultFolder, newComplementPasskey)).Should().BeTrue(); + + var configuredOptions = await vaultService.GetVaultOptionsAsync(vaultFolder); + configuredOptions.UnlockProcedure.Should().BeEquivalentTo(biometricProcedure); + } + + [Test] + public async Task ModifyComplementation_RemoveComplement_RestoresPrimaryOnlyUnlock() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultService = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + var passwordOnlyProcedure = new AuthenticationMethod([AUTH_PASSWORD], null); + using var keyFile = await CreatePasswordVaultWithKeyFileComplementAsync(manager, vaultFolder, "Password#1", vaultId); + + using var unlockPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var unlockContract = await manager.UnlockAsync(vaultFolder, unlockPasskey); + + // Act + await manager.ModifyComplementationAsync(vaultFolder, unlockContract, new() + { + CurrentCredential = unlockPasskey + }, CreateOptions(passwordOnlyProcedure, vaultId)); + + // Assert + using var passwordOnlyPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var keyFileOnlyPasskey = keyFile.CreateCopy(); + + (await CanUnlockAsync(manager, vaultFolder, passwordOnlyPasskey)).Should().BeTrue(); + (await CanUnlockAsync(manager, vaultFolder, keyFileOnlyPasskey)).Should().BeFalse(); + + var configuredOptions = await vaultService.GetVaultOptionsAsync(vaultFolder); + var shares = await new VaultReader(vaultFolder, StreamSerializer.Instance).ReadComplementationAsync(CancellationToken.None); + + configuredOptions.UnlockProcedure.Should().BeEquivalentTo(passwordOnlyProcedure); + shares.Should().BeNull(); + } + + [Test] + public async Task ModifyComplementation_ChangePrimaryWithComplement_PreservesComplementUnlock() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + var complementedProcedure = new AuthenticationMethod([AUTH_PASSWORD], AUTH_KEYFILE); + using var keyFile = await CreatePasswordVaultWithKeyFileComplementAsync(manager, vaultFolder, "Password#1", vaultId); + + using var keyFileUnlock = keyFile.CreateCopy(); + using var unlockContract = await manager.UnlockAsync(vaultFolder, keyFileUnlock); + using var newPassword = await GetPasswordCreationCredentialAsync("Password#2"); + + // Act + await manager.ModifyComplementationAsync(vaultFolder, unlockContract, new() + { + CurrentCredential = keyFile, + NewPrimaryCredential = newPassword + }, CreateOptions(complementedProcedure, vaultId)); + + // Assert + using var oldPasswordPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var newPasswordPasskey = await GetPasswordLoginCredentialAsync("Password#2"); + using var keyFileOnlyPasskey = keyFile.CreateCopy(); + + (await CanUnlockAsync(manager, vaultFolder, oldPasswordPasskey)).Should().BeFalse(); + (await CanUnlockAsync(manager, vaultFolder, newPasswordPasskey)).Should().BeTrue(); + (await CanUnlockAsync(manager, vaultFolder, keyFileOnlyPasskey)).Should().BeTrue(); + } + private static IFolder CreateVaultFolder() { var path = Path.Combine(Path.DirectorySeparatorChar.ToString(), $"TestVault-{Guid.NewGuid():N}"); @@ -304,6 +456,35 @@ private static async Task GetKeyFileLoginCredentialAsync(IFolder vaul : throw result.Exception ?? new InvalidOperationException("Key file login credential was not provided."); } + private static async Task CreatePasswordVaultWithKeyFileComplementAsync(IVaultManagerService manager, IFolder vaultFolder, string password, string vaultId) + { + var passwordProcedure = new AuthenticationMethod([AUTH_PASSWORD], null); + var complementedProcedure = new AuthenticationMethod([AUTH_PASSWORD], AUTH_KEYFILE); + + using var passwordKey = await GetPasswordCreationCredentialAsync(password); + using var _ = await manager.CreateAsync(vaultFolder, passwordKey, CreateOptions(passwordProcedure, vaultId)); + + using var unlockPasskey = await GetPasswordLoginCredentialAsync(password); + using var unlockContract = await manager.UnlockAsync(vaultFolder, unlockPasskey); + var keyFile = await GetKeyFileCreationCredentialAsync(vaultId); + + try + { + await manager.ModifyComplementationAsync(vaultFolder, unlockContract, new() + { + CurrentCredential = unlockPasskey, + NewComplementCredential = keyFile + }, CreateOptions(complementedProcedure, vaultId)); + + return keyFile; + } + catch + { + keyFile.Dispose(); + throw; + } + } + private static async Task CanUnlockAsync(IVaultManagerService manager, IFolder vaultFolder, IKeyUsage passkey) { try @@ -318,4 +499,3 @@ private static async Task CanUnlockAsync(IVaultManagerService manager, IFo } } } - From 986eababee1f7de8cd3bacf34318c503d6d1076d Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 1 May 2026 22:14:15 +0200 Subject: [PATCH 05/11] Fixed tests --- .../Helpers/MockVaultHelpers.Latest.cs | 2 +- .../Helpers/MockVaultHelpers.V4.cs | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs diff --git a/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.Latest.cs b/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.Latest.cs index 73957ff5f..6a9d5d13d 100644 --- a/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.Latest.cs +++ b/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.Latest.cs @@ -7,7 +7,7 @@ internal static partial class MockVaultHelpers { public static async Task<(IFolder, string)> CreateVaultLatestAsync(MockVaultOptions? options, CancellationToken cancellationToken = default) { - return await CreateVaultV3Async(options, cancellationToken); + return await CreateVaultV4Async(options, cancellationToken); } } } diff --git a/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs b/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs new file mode 100644 index 000000000..622e32796 --- /dev/null +++ b/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs @@ -0,0 +1,41 @@ +using OwlCore.Storage; +using SecureFolderFS.Tests.Models; + +namespace SecureFolderFS.Tests.Helpers +{ + internal static partial class MockVaultHelpers + { + // Mock recovery key + public const string V4_RECOVERY_KEY = "osNie57du4qOxSkuxcyYd36RsQI3xPVnUpPad/aych4=@@@7wmweOio0sGGljnvBiggxo/65ZlvTnTfVNFmwFZ7W8g="; + + private const string V4_KEYSTORE_STRING = """ + { + "c_encryptionKey": "d0qVlgnquQr1NID0IdtrTd4pqzHkGxXWhHSpkrPuYtDXjVbm3ODpwQ==", + "c_macKey": "ZKhu3nbUjoqV6ZGtU/gitauQBuC76iCwuDnLD6oa3Pav1srYcQN/zw==", + "salt": "OEpZjy18/dbbS+i2LlmKjA==", + "c_softwareEntropy": "T9dHlJg4WLmuKM4Qz1rcdIn0QsCdiGNNtU6EyzN03OA=", + "entropyNonce": "buZqCJGGsukm87mP", + "entropyTag": "AvsZawlWTMG3gBpuavmu4g==" + } + """; + + private const string V4_SFCONFIG_STRING = """ + { + "contentCipherScheme": "XChaCha20-Poly1305", + "filenameCipherScheme": "AES-SIV", + "filenameEncoding": "Base4K", + "recycleBinSize": -1, + "authMode": "password", + "vaultId": "8a1fbf8a-b986-4f8a-a3d7-59df8d203e3a", + "hmacsha256mac": "/gLIyGTCd8Y/bvj3YxY7JmmFEbCX3iDGYFVmeNMNw8w=", + "version": 4 + } + """; + + public static async Task<(IFolder, string)> CreateVaultV4Async(MockVaultOptions? options, + CancellationToken cancellationToken = default) + { + return (await SetupMockVault(V4_SFCONFIG_STRING, V4_KEYSTORE_STRING, options, cancellationToken), V4_RECOVERY_KEY); + } + } +} From 09326ad7b261f785de78669cce78105572da80f0 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 1 May 2026 22:51:42 +0200 Subject: [PATCH 06/11] Added support for complementation on MAUI --- .../DataModels/VaultSharesDataModel.cs | 1 + .../Popups/CredentialsPopup.xaml | 8 ++ .../Views/Vault/LoginPage.xaml | 89 +++++++++++-------- .../Views/Vault/LoginPage.xaml.cs | 9 ++ 4 files changed, 71 insertions(+), 36 deletions(-) diff --git a/src/Core/SecureFolderFS.Core/DataModels/VaultSharesDataModel.cs b/src/Core/SecureFolderFS.Core/DataModels/VaultSharesDataModel.cs index 5642a26ae..4c13d89c4 100644 --- a/src/Core/SecureFolderFS.Core/DataModels/VaultSharesDataModel.cs +++ b/src/Core/SecureFolderFS.Core/DataModels/VaultSharesDataModel.cs @@ -7,6 +7,7 @@ namespace SecureFolderFS.Core.DataModels [Serializable] public sealed record class VaultSharesDataModel { + [JsonPropertyName("shares")] public List? Shares { get; init; } } diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml index 4471df128..08a5c8ec0 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml @@ -80,6 +80,14 @@ + + + + diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml index 6151047f7..3d1e9bc40 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml @@ -8,14 +8,17 @@ xmlns:mi_cupertino="clr-namespace:MauiIcons.Cupertino;assembly=MauiIcons.Cupertino" xmlns:mi_material="clr-namespace:MauiIcons.Material;assembly=MauiIcons.Material" xmlns:uc="clr-namespace:SecureFolderFS.Maui.UserControls" + xmlns:ucc="clr-namespace:SecureFolderFS.Maui.UserControls.Common" xmlns:uco="clr-namespace:SecureFolderFS.Maui.UserControls.Options" + xmlns:vm="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls.Authentication;assembly=SecureFolderFS.Sdk" + x:Name="ThisPage" Title="{Binding ViewModel.VaultViewModel.Title, Mode=OneWay}" x:DataType="local:LoginPage"> - + - - + + + - - - - - - -