diff --git a/.gitmodules b/.gitmodules index 44f18cf87..5f32f0dc6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,9 @@ [submodule "lib/Tmds.Fuse"] path = lib/Tmds.Fuse url = https://github.com/securefolderfs-community/Tmds.Fuse -[submodule "src/Platforms/SecureFolderFS.Dashboard"] - path = src/Platforms/SecureFolderFS.Dashboard - url = git@github.com:securefolderfs-community/SecureFolderFS.Dashboard.git +[submodule "src/Platforms/SecureFolderFS.AppPlatform"] + path = src/Platforms/SecureFolderFS.AppPlatform + url = git@github.com:securefolderfs-community/SecureFolderFS.AppPlatform.git +[submodule "src/Platforms/SecureFolderFS.AppPlatform.Server"] + path = src/Platforms/SecureFolderFS.AppPlatform.Server + url = git@github.com:securefolderfs-community/SecureFolderFS.AppPlatform.Server.git diff --git a/SecureFolderFS.sln b/SecureFolderFS.sln index 5014e4c24..5a37ab4fa 100644 --- a/SecureFolderFS.sln +++ b/SecureFolderFS.sln @@ -64,7 +64,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{724C2A9B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Tests", "tests\SecureFolderFS.Tests\SecureFolderFS.Tests.csproj", "{67ED86B1-D287-4F36-A8BE-189F68502B4C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Dashboard", "src\Platforms\SecureFolderFS.Dashboard\SecureFolderFS.Dashboard.csproj", "{9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.AppPlatform", "src\Platforms\SecureFolderFS.AppPlatform\SecureFolderFS.AppPlatform.csproj", "{9CF66911-1E7E-4A82-B7B4-97B2DE8BA9B0}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Sdk.Ftp", "src\Sdk\SecureFolderFS.Sdk.Ftp\SecureFolderFS.Sdk.Ftp.csproj", "{17592A5B-EFB4-478C-87A1-C4A10BDECA50}" EndProject @@ -82,6 +82,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Sdk.Dropbox" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Sdk.WebDavClient", "src\Sdk\SecureFolderFS.Sdk.WebDavClient\SecureFolderFS.Sdk.WebDavClient.csproj", "{E9D21865-C31B-49AD-B9CE-A8A9491789D5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.AppPlatform.Server", "src\Platforms\SecureFolderFS.AppPlatform.Server\SecureFolderFS.AppPlatform.Server.csproj", "{4440EBF8-9707-41DD-A723-F52987F83E1F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -558,6 +560,22 @@ Global {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Release|x64.Build.0 = Release|Any CPU {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Release|x86.ActiveCfg = Release|Any CPU {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Release|x86.Build.0 = Release|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Debug|arm64.ActiveCfg = Debug|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Debug|arm64.Build.0 = Debug|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Debug|x64.ActiveCfg = Debug|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Debug|x64.Build.0 = Debug|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Debug|x86.ActiveCfg = Debug|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Debug|x86.Build.0 = Debug|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Release|Any CPU.Build.0 = Release|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Release|arm64.ActiveCfg = Release|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Release|arm64.Build.0 = Release|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Release|x64.ActiveCfg = Release|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Release|x64.Build.0 = Release|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Release|x86.ActiveCfg = Release|Any CPU + {4440EBF8-9707-41DD-A723-F52987F83E1F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -596,6 +614,7 @@ Global {85FE77EA-9F89-4F42-BD79-26C82F847DDC} = {086CDAC6-2730-4F09-BA28-B41F737E6C4D} {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2} = {086CDAC6-2730-4F09-BA28-B41F737E6C4D} {E9D21865-C31B-49AD-B9CE-A8A9491789D5} = {086CDAC6-2730-4F09-BA28-B41F737E6C4D} + {4440EBF8-9707-41DD-A723-F52987F83E1F} = {66BC1E2B-D99A-49E2-8B8F-EF7851493CB0} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A1906FD8-BB54-4688-BC0F-9ED7532D2CB0} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs index 13cd037e4..646878fde 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs @@ -80,6 +80,9 @@ public static async Task RestoreAsync(IStorableChild recycleBinItem, IModifiable // A new item name should be chosen fit for the new folder (so that Directory ID match) var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); + // Get an available name if the destination already exists + ciphertextName = await GetAvailableDestinationNameAsync(ciphertextDestinationFolder, ciphertextName, plaintextOriginalName, specifics, cancellationToken); + // Rename and move item to destination _ = await ciphertextDestinationFolder.MoveStorableFromAsync(recycleBinItem, modifiableRecycleBin, false, ciphertextName, null, cancellationToken); } @@ -89,6 +92,9 @@ public static async Task RestoreAsync(IStorableChild recycleBinItem, IModifiable // The same name could be used since the Directory IDs match var ciphertextName = Path.ChangeExtension(await AbstractPathHelpers.EncryptNameAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken), Constants.Names.ENCRYPTED_FILE_EXTENSION); + // Get an available name if the destination already exists + ciphertextName = await GetAvailableDestinationNameAsync(ciphertextDestinationFolder, ciphertextName, plaintextOriginalName, specifics, cancellationToken); + // Rename and move item to destination _ = await ciphertextDestinationFolder.MoveStorableFromAsync(recycleBinItem, modifiableRecycleBin, false, ciphertextName, null, cancellationToken); } @@ -216,6 +222,28 @@ public static async Task DeleteOrRecycleAsync( } } + private static async Task GetAvailableDestinationNameAsync(IFolder ciphertextDestinationFolder, string ciphertextName, string plaintextOriginalName, FileSystemSpecifics specifics, CancellationToken cancellationToken) + { + // Check if the item already exists + var existing = await ciphertextDestinationFolder.TryGetFirstByNameAsync(ciphertextName, cancellationToken); + if (existing is not null) + { + // If the item already exists, append a suffix to the name + var nameWithoutExtension = Path.GetFileNameWithoutExtension(plaintextOriginalName); + var extension = Path.GetExtension(plaintextOriginalName); + var suffix = 1; + do + { + var newPlaintextName = $"{nameWithoutExtension} ({suffix}){extension}"; + ciphertextName = Path.ChangeExtension(await AbstractPathHelpers.EncryptNameAsync(newPlaintextName, ciphertextDestinationFolder, specifics, cancellationToken), Constants.Names.ENCRYPTED_FILE_EXTENSION); + existing = await ciphertextDestinationFolder.TryGetFirstByNameAsync(ciphertextName, cancellationToken); + suffix++; + } while (existing is not null); + } + + return ciphertextName; + } + private static async Task IsRecentlyCreatedAsync(IStorable storable, CancellationToken cancellationToken) { try diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs index 63fa5c6eb..d619238eb 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs @@ -38,6 +38,7 @@ public override async Task ValidateResultAsync((IFolder, IProgress public void SetOptions(VaultOptions vaultOptions) { - _configDataModel = VaultConfigurationDataModel.FromVaultOptions(vaultOptions); + if (vaultOptions.AppPlatform is null) + { + _configDataModel = VaultConfigurationDataModel.FromVaultOptions(vaultOptions); + _v4ConfigDataModel = null; + } + else + { + _v4ConfigDataModel = V4VaultConfigurationDataModel.V4FromVaultOptions(vaultOptions); + _configDataModel = _v4ConfigDataModel.ToVaultConfigurationDataModel(); + } } /// @@ -95,13 +105,19 @@ public async Task FinalizeAsync(CancellationToken cancellationToken // First, we need to fill in the PayloadMac of the content _macKey.UseKey(macKey => { - VaultParser.CalculateConfigMac(_configDataModel, macKey, _configDataModel.PayloadMac); + if (_v4ConfigDataModel is not null) + VaultParser.V4CalculateConfigMac(_v4ConfigDataModel, macKey, _v4ConfigDataModel.PayloadMac); + else + VaultParser.CalculateConfigMac(_configDataModel, macKey, _configDataModel.PayloadMac); }); // Write the whole configuration await _vaultWriter.WriteKeystoreAsync(_keystoreDataModel, cancellationToken); //await _vaultWriter.WriteV4KeystoreAsync(_v4KeystoreDataModel, cancellationToken); - await _vaultWriter.WriteConfigurationAsync(_configDataModel, cancellationToken); + if (_v4ConfigDataModel is not null) + await _vaultWriter.WriteV4ConfigurationAsync(_v4ConfigDataModel, cancellationToken); + else + await _vaultWriter.WriteConfigurationAsync(_configDataModel, cancellationToken); // Create the content folder if (_vaultFolder is IModifiableFolder modifiableFolder) diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs index 7345717b2..eb03e2336 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs @@ -23,6 +23,7 @@ internal sealed class ModifyCredentialsRoutine : IModifyCredentialsRoutine private V3VaultKeystoreDataModel? _keystoreDataModel; private V4VaultKeystoreDataModel? _v4KeystoreDataModel; private VaultConfigurationDataModel? _configDataModel; + private V4VaultConfigurationDataModel? _v4ConfigDataModel; public ModifyCredentialsRoutine(VaultReader vaultReader, VaultWriter vaultWriter) { @@ -49,7 +50,16 @@ public void SetUnlockContract(IDisposable unlockContract) /// public void SetOptions(VaultOptions vaultOptions) { - _configDataModel = VaultConfigurationDataModel.FromVaultOptions(vaultOptions); + if (vaultOptions.AppPlatform is null) + { + _configDataModel = VaultConfigurationDataModel.FromVaultOptions(vaultOptions); + _v4ConfigDataModel = null; + } + else + { + _v4ConfigDataModel = V4VaultConfigurationDataModel.V4FromVaultOptions(vaultOptions); + _configDataModel = _v4ConfigDataModel.ToVaultConfigurationDataModel(); + } } /// @@ -137,13 +147,19 @@ public async Task FinalizeAsync(CancellationToken cancellationToken // First, we need to fill in the PayloadMac of the content _keyPair.MacKey.UseKey(macKey => { - VaultParser.CalculateConfigMac(_configDataModel, macKey, _configDataModel.PayloadMac); + if (_v4ConfigDataModel is not null) + VaultParser.V4CalculateConfigMac(_v4ConfigDataModel, macKey, _v4ConfigDataModel.PayloadMac); + else + VaultParser.CalculateConfigMac(_configDataModel, macKey, _configDataModel.PayloadMac); }); // Write the whole configuration await _vaultWriter.WriteKeystoreAsync(_keystoreDataModel, cancellationToken); //await _vaultWriter.WriteKeystoreAsync(_v4KeystoreDataModel, cancellationToken); - await _vaultWriter.WriteConfigurationAsync(_configDataModel, cancellationToken); + if (_v4ConfigDataModel is not null) + await _vaultWriter.WriteV4ConfigurationAsync(_v4ConfigDataModel, cancellationToken); + else + await _vaultWriter.WriteConfigurationAsync(_configDataModel, cancellationToken); // Key copies need to be created because the original ones are disposed of here using (_keyPair) diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs index a12e7f748..54a555b4f 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs @@ -15,6 +15,7 @@ public sealed class RecoverRoutine : ICredentialsRoutine, IFinalizationRoutine { private readonly VaultReader _vaultReader; private VaultConfigurationDataModel? _configDataModel; + private V4VaultConfigurationDataModel? _v4ConfigDataModel; private KeyPair? _keyPair; public RecoverRoutine(VaultReader vaultReader) @@ -26,6 +27,18 @@ public RecoverRoutine(VaultReader vaultReader) public async Task InitAsync(CancellationToken cancellationToken) { _configDataModel = await _vaultReader.ReadConfigurationAsync(cancellationToken); + + if (_configDataModel.AuthenticationMethod.Contains(Constants.Vault.Authentication.AUTH_APP_PLATFORM, StringComparison.Ordinal)) + { + try + { + _v4ConfigDataModel = await _vaultReader.ReadV4ConfigurationAsync(cancellationToken); + } + catch (Exception) + { + _v4ConfigDataModel = null; + } + } } /// @@ -44,7 +57,10 @@ public async Task FinalizeAsync(CancellationToken cancellationToken { // Check if the payload has not been tampered with var validator = new ConfigurationValidator(_keyPair.MacKey); - await validator.ValidateAsync(_configDataModel, cancellationToken); + if (_v4ConfigDataModel is not null) + await validator.V4ValidateAsync(_v4ConfigDataModel, cancellationToken); + else + await validator.ValidateAsync(_configDataModel, cancellationToken); // In this case, we rely on the consumer to take ownership of the keys, and thus manage their lifetimes // Key copies need to be created because the original ones are disposed of here diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs index 6a3b8c195..0c096a8c6 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs @@ -17,6 +17,7 @@ internal sealed class UnlockRoutine : ICredentialsRoutine private V3VaultKeystoreDataModel? _keystoreDataModel; private V4VaultKeystoreDataModel? _v4KeystoreDataModel; private VaultConfigurationDataModel? _configDataModel; + private V4VaultConfigurationDataModel? _v4ConfigDataModel; private SecureKey? _dekKey; private SecureKey? _macKey; @@ -30,6 +31,18 @@ public async Task InitAsync(CancellationToken cancellationToken) { _configDataModel = await _vaultReader.ReadConfigurationAsync(cancellationToken); + if (_configDataModel.AuthenticationMethod.Contains(Constants.Vault.Authentication.AUTH_APP_PLATFORM, StringComparison.Ordinal)) + { + try + { + _v4ConfigDataModel = await _vaultReader.ReadV4ConfigurationAsync(cancellationToken); + } + catch (Exception) + { + _v4ConfigDataModel = null; + } + } + if (_configDataModel.Version >= Constants.Vault.Versions.V4) _v4KeystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); else @@ -71,7 +84,10 @@ public async Task FinalizeAsync(CancellationToken cancellationToken { // Check if the payload has not been tampered with var validator = new ConfigurationValidator(_macKey); - await validator.ValidateAsync(_configDataModel, cancellationToken); + if (_v4ConfigDataModel is not null) + await validator.V4ValidateAsync(_v4ConfigDataModel, cancellationToken); + else + await validator.ValidateAsync(_configDataModel, cancellationToken); // In this case, we rely on the consumer to take ownership of the keys, and thus manage their lifetimes // Key copies need to be created because the original ones are disposed of here diff --git a/src/Core/SecureFolderFS.Core/Validators/ConfigurationValidator.cs b/src/Core/SecureFolderFS.Core/Validators/ConfigurationValidator.cs index 3019a25b9..bd2c71a91 100644 --- a/src/Core/SecureFolderFS.Core/Validators/ConfigurationValidator.cs +++ b/src/Core/SecureFolderFS.Core/Validators/ConfigurationValidator.cs @@ -25,6 +25,12 @@ public async Task ValidateAsync(VaultConfigurationDataModel value, CancellationT await Task.CompletedTask; } + public async Task V4ValidateAsync(V4VaultConfigurationDataModel value, CancellationToken cancellationToken = default) + { + V4Validate(value); + await Task.CompletedTask; + } + [SkipLocalsInit] private void Validate(VaultConfigurationDataModel value) { @@ -41,5 +47,19 @@ private void Validate(VaultConfigurationDataModel value) if (!isEqual) throw new CryptographicException("Vault hash doesn't match the computed hash."); } + + [SkipLocalsInit] + private void V4Validate(V4VaultConfigurationDataModel value) + { + var isEqual = _macKey.UseKey(macKey => + { + Span payloadMac = stackalloc byte[HMACSHA256.HashSizeInBytes]; + VaultParser.V4CalculateConfigMac(value, macKey, payloadMac); + return CryptographicOperations.FixedTimeEquals(payloadMac, value.PayloadMac); + }); + + if (!isEqual) + throw new CryptographicException("Vault hash doesn't match the computed hash."); + } } } diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs index 7b7b1afe0..5dee36a09 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs @@ -36,6 +36,26 @@ public static void CalculateConfigMac(VaultConfigurationDataModel configDataMode hmacSha256.GetCurrentHash(mac); } + public static void V4CalculateConfigMac(V4VaultConfigurationDataModel configDataModel, ReadOnlySpan macKey, Span mac) + { + using var hmacSha256 = new HMACSHA256(macKey.ToArray()); + + hmacSha256.AppendData(BitConverter.GetBytes(configDataModel.Version)); + hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.ContentCipherId(configDataModel.ContentCipherId))); + hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.FileNameCipherId(configDataModel.FileNameCipherId))); + hmacSha256.AppendData(BitConverter.GetBytes(configDataModel.RecycleBinSize)); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.FileNameEncodingId)); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.Uid)); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.ServerUrl ?? string.Empty)); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.VaultResource ?? string.Empty)); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.Organization ?? string.Empty)); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.AccessTokenEndpoint ?? string.Empty)); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.DeviceRegistrationEndpoint ?? string.Empty)); + hmacSha256.AppendFinalData(Encoding.UTF8.GetBytes(configDataModel.AuthenticationMethod)); + + hmacSha256.GetCurrentHash(mac); + } + /// /// Derives DEK and MAC keys from provided credentials. /// diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs index ffa8a326a..304ed0faa 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs @@ -45,6 +45,14 @@ public async Task ReadConfigurationAsync(Cancellati return await ReadDataAsync(configFile, _serializer, cancellationToken); } + public async Task ReadV4ConfigurationAsync(CancellationToken cancellationToken) + { + if (await _vaultFolder.GetFirstByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken) is not IFile configFile) + throw new FileNotFoundException("The configuration file was not found."); + + return await ReadDataAsync(configFile, _serializer, cancellationToken); + } + 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 92943e572..d30715db7 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs @@ -46,6 +46,17 @@ public async Task WriteConfigurationAsync(VaultConfigurationDataModel? configDat await WriteDataAsync(configFile, configDataModel, cancellationToken); } + public async Task WriteV4ConfigurationAsync(V4VaultConfigurationDataModel? configDataModel, CancellationToken cancellationToken) + { + var configFile = configDataModel is null ? null : _vaultFolder switch + { + IModifiableFolder modifiableFolder => await modifiableFolder.CreateFileAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, true, cancellationToken), + _ => await _vaultFolder.GetFirstByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken) as IFile + }; + + await WriteDataAsync(configFile, configDataModel, cancellationToken); + } + public async Task WriteAuthenticationAsync(string fileName, TCapability? authDataModel, CancellationToken cancellationToken) where TCapability : VaultCapabilityDataModel { diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props index f519dc960..cdf9fc188 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -17,6 +17,7 @@ + @@ -47,4 +48,4 @@ - \ No newline at end of file + diff --git a/src/Platforms/SecureFolderFS.AppPlatform b/src/Platforms/SecureFolderFS.AppPlatform new file mode 160000 index 000000000..fb9e3e9a7 --- /dev/null +++ b/src/Platforms/SecureFolderFS.AppPlatform @@ -0,0 +1 @@ +Subproject commit fb9e3e9a78c3df29b60c8335986f0ae47ad7a80b diff --git a/src/Platforms/SecureFolderFS.AppPlatform.Server b/src/Platforms/SecureFolderFS.AppPlatform.Server new file mode 160000 index 000000000..559a82d60 --- /dev/null +++ b/src/Platforms/SecureFolderFS.AppPlatform.Server @@ -0,0 +1 @@ +Subproject commit 559a82d60669326b738ed0436a41549c357e8ecd diff --git a/src/Platforms/SecureFolderFS.Dashboard b/src/Platforms/SecureFolderFS.Dashboard deleted file mode 160000 index ad1dd04b3..000000000 --- a/src/Platforms/SecureFolderFS.Dashboard +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ad1dd04b314f92045292b63bda907787a7c44b28 diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs index 2da5fd153..87e778b27 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs @@ -20,6 +20,25 @@ namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation /// internal sealed class AndroidFileExplorerService : IFileExplorerService { + /// + public async Task> PickGalleryItemsAsync(CancellationToken cancellationToken = default) + { + var intent = new Intent(Intent.ActionOpenDocument) + .AddCategory(Intent.CategoryOpenable) + .PutExtra(Intent.ExtraAllowMultiple, true) + .SetType("*/*") + .PutExtra(Intent.ExtraMimeTypes, new[] { "image/*", "video/*" }); + + var pickerIntent = Intent.CreateChooser(intent, "Select photos or videos"); + + // GalleryPicker 0x2AFA + var uris = await StartActivityForUrisAsync(pickerIntent, 0x2AFA); + if (uris.Count == 0 || MainActivity.Instance is null) + return []; + + return uris.Select(x => (IStorable)new AndroidFile(x, MainActivity.Instance)).ToArray(); + } + /// public async Task PickFileAsync(PickerOptions? options, bool offerPersistence = true, CancellationToken cancellationToken = default) { @@ -124,24 +143,43 @@ public async Task SaveFileAsync(string suggestedName, Stream dataStream, I private async Task StartActivityAsync(Intent? pickerIntent, int requestCode) { - AndroidUri? resultUri = null; + var uris = await StartActivityForUrisAsync(pickerIntent, requestCode); + return uris.FirstOrDefault(); + } + + private async Task> StartActivityForUrisAsync(Intent? pickerIntent, int requestCode) + { var tcs = new TaskCompletionSource(); var activity = MainActivity.Instance; if (activity is null) - return null; + return []; activity.ActivityResult += OnActivityResult; activity.StartActivityForResult(pickerIntent, requestCode); var result = await tcs.Task; - if (result?.Data is { } uri) - resultUri = uri; - if (result?.HasExtra("error") == true) throw new Exception(result.GetStringExtra("error")); - return resultUri; + if (result is null) + return []; + + var uris = new List(); + if (result.Data is { } singleUri) + uris.Add(singleUri); + + if (result.ClipData is { } clipData) + { + for (var i = 0; i < clipData.ItemCount; i++) + { + var clipUri = clipData.GetItemAt(i)?.Uri; + if (clipUri is not null) + uris.Add(clipUri); + } + } + + return uris.DistinctBy(x => x.ToString()).ToArray(); void OnActivityResult(int rC, Result resultCode, Intent? data) { diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs index 3c61eb2ed..c46532dde 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs @@ -2,9 +2,11 @@ using CommunityToolkit.Maui.Storage; using Foundation; using OwlCore.Storage; +using PhotosUI; using SecureFolderFS.Maui.Platforms.iOS.Storage; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Storage.MemoryStorageEx; using SecureFolderFS.Storage.Pickers; using UIKit; using UniformTypeIdentifiers; @@ -15,6 +17,34 @@ namespace SecureFolderFS.Maui.Platforms.iOS.ServiceImplementation [SuppressMessage("Interoperability", "CA1422:Validate platform compatibility")] internal sealed class IOSFileExplorerService : IFileExplorerService { + private record struct PickedGalleryItem(NSData Data, string Name, string TypeIdentifier); + + /// + public async Task> PickGalleryItemsAsync(CancellationToken cancellationToken = default) + { + var pickerConfiguration = new PHPickerConfiguration() + { + SelectionLimit = 0 + }; + + using var picker = new PHPickerViewController(pickerConfiguration); + var pickedFiles = await PickGalleryInternalAsync(picker, cancellationToken); + + return pickedFiles + .Select(x => + { + var buffer = x.Data.ToArray(); + var ext = GetExtensionForTypeIdentifier(x.TypeIdentifier, buffer.AsSpan(0, 64)); + var baseName = x.Name; + var fullName = string.IsNullOrEmpty(ext) ? baseName : $"{baseName}{ext}"; + return (IStorable)new MemoryFileEx( + $"/{fullName}", + fullName, + new MemoryStream(buffer)); + }) + .ToArray(); + } + /// public async Task TryOpenInFileExplorerAsync(IFolder folder, CancellationToken cancellationToken = default) { @@ -93,5 +123,105 @@ void DocumentPicker_DidPickDocumentAtUrls(object? sender, UIDocumentPickedAtUrls tcs.TrySetResult(e.Urls[0]); } } + + private static async Task> PickGalleryInternalAsync(PHPickerViewController picker, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource>(); + + var currentViewController = Platform.GetCurrentUIViewController(); + if (currentViewController is null) + throw new InvalidOperationException("Unable to get the UI View Controller for gallery picker."); + + var pickerDelegate = new PickerDelegate(async results => + { + picker.DismissViewController(true, null); + + if (results.Length == 0) + { + _ = tcs.TrySetResult([]); + return; + } + + var storageTasks = results + .Where(x => x.ItemProvider.HasItemConformingTo(UTTypes.Image.Identifier) || x.ItemProvider.HasItemConformingTo(UTTypes.Movie.Identifier)) + .Select(result => LoadPickedFileAsync(result.ItemProvider)) + .ToArray(); + + var loadedItems = await Task.WhenAll(storageTasks); + var pickedGalleryItems = loadedItems.Where(x => x is not null).Select(x => (PickedGalleryItem)x!); + _ = tcs.TrySetResult(pickedGalleryItems.ToArray()); + }); + + picker.Delegate = pickerDelegate; + currentViewController.PresentViewController(picker, true, null); + + return await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private static Task LoadPickedFileAsync(NSItemProvider itemProvider) + { + var tcs = new TaskCompletionSource(); + + // Try concrete types first, so PreferredFilenameExtension always resolves + string typeIdentifier; + if (itemProvider.HasItemConformingTo(UTTypes.Jpeg.Identifier)) + typeIdentifier = UTTypes.Jpeg.Identifier; + else if (itemProvider.HasItemConformingTo(UTTypes.Heic.Identifier)) + typeIdentifier = UTTypes.Heic.Identifier; + else if (itemProvider.HasItemConformingTo(UTTypes.Png.Identifier)) + typeIdentifier = UTTypes.Png.Identifier; + else if (itemProvider.HasItemConformingTo(UTTypes.Gif.Identifier)) + typeIdentifier = UTTypes.Gif.Identifier; + else if (itemProvider.HasItemConformingTo(UTTypes.Mpeg4Movie.Identifier)) + typeIdentifier = UTTypes.Mpeg4Movie.Identifier; + else if (itemProvider.HasItemConformingTo(UTTypes.QuickTimeMovie.Identifier)) + typeIdentifier = UTTypes.QuickTimeMovie.Identifier; + else if (itemProvider.HasItemConformingTo(UTTypes.Movie.Identifier)) + typeIdentifier = UTTypes.Movie.Identifier; + else + typeIdentifier = UTTypes.Image.Identifier; // Last resort abstract fallback + + itemProvider.LoadDataRepresentation(typeIdentifier, (data, error) => + { + if (error is not null || data is null || itemProvider.SuggestedName is null) + { + _ = tcs.TrySetResult(null); + return; + } + + _ = tcs.TrySetResult(new(data, itemProvider.SuggestedName, typeIdentifier)); + }); + + return tcs.Task; + } + + private static string GetExtensionForTypeIdentifier(string? typeIdentifier, ReadOnlySpan imageBytes64) + { + if (typeIdentifier is not null) + { + var utType = UTType.CreateFromIdentifier(typeIdentifier); + if (utType?.PreferredFilenameExtension is { Length: > 0 } ext) + return $".{ext}"; + } + + // Sniff magic bytes before falling back to .jpg + if (imageBytes64.Length >= 8) + { + if (imageBytes64[0] == 0xFF && imageBytes64[1] == 0xD8) return ".jpg"; + if (imageBytes64[0] == 0x89 && imageBytes64[1] == 0x50) return ".png"; + if (imageBytes64[0] == 0x47 && imageBytes64[1] == 0x49) return ".gif"; + if (imageBytes64[4] == 0x66 && imageBytes64[5] == 0x74 && imageBytes64[6] == 0x79 && imageBytes64[7] == 0x70) return ".mp4"; + } + + return ".jpg"; + } + + private sealed class PickerDelegate(Func onFinished) : PHPickerViewControllerDelegate + { + public override void DidFinishPicking(PHPickerViewController picker, PHPickerResult[] results) + { + _ = onFinished(results); + } + } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Prompts/StorableTypePrompt.cs b/src/Platforms/SecureFolderFS.Maui/Prompts/StorableTypePrompt.cs index 9588fe0ec..f9cb1a16e 100644 --- a/src/Platforms/SecureFolderFS.Maui/Prompts/StorableTypePrompt.cs +++ b/src/Platforms/SecureFolderFS.Maui/Prompts/StorableTypePrompt.cs @@ -20,20 +20,37 @@ public async Task ShowAsync() var page = Shell.Current.CurrentPage; var fileText = "File".ToLocalized(); var folderText = "Folder".ToLocalized(); + var galleryText = "Gallery".ToLocalized(); + var options = ViewModel.IncludeGallery + ? new[] { fileText, folderText, galleryText } + : new[] { fileText, folderText }; var chosenOption = await page.DisplayActionSheetAsync( ViewModel.Title, "Cancel".ToLocalized(), null, - fileText, folderText); + options); + + ViewModel.SelectedOption = null; if (chosenOption == fileText) + { ViewModel.StorableType = StorableType.File; + ViewModel.SelectedOption = nameof(StorableType.File); + } else if (chosenOption == folderText) + { ViewModel.StorableType = StorableType.Folder; + ViewModel.SelectedOption = nameof(StorableType.Folder); + } + else if (ViewModel.IncludeGallery && chosenOption == galleryText) + { + ViewModel.StorableType = StorableType.None; + ViewModel.SelectedOption = galleryText; + } else ViewModel.StorableType = StorableType.None; - return ViewModel.StorableType == StorableType.None ? Result.Failure(null) : Result.Success; + return string.IsNullOrEmpty(ViewModel.SelectedOption) ? Result.Failure(null) : Result.Success; } /// diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/IntroductionSlide.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/IntroductionSlide.xaml index 9155ccbdb..a9a682f15 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/IntroductionSlide.xaml +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/IntroductionSlide.xaml @@ -4,7 +4,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Name="ThisControl"> - +