diff --git a/SecureFolderFS.sln b/SecureFolderFS.sln index 2db6f4db5..5014e4c24 100644 --- a/SecureFolderFS.sln +++ b/SecureFolderFS.sln @@ -76,6 +76,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Core.WinFsp" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Cli", "src\Platforms\SecureFolderFS.Cli\SecureFolderFS.Cli.csproj", "{A9219707-C494-4D3B-8123-43652707B516}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Sdk.DeviceLink", "src\Sdk\SecureFolderFS.Sdk.DeviceLink\SecureFolderFS.Sdk.DeviceLink.csproj", "{85FE77EA-9F89-4F42-BD79-26C82F847DDC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecureFolderFS.Sdk.Dropbox", "src\Sdk\SecureFolderFS.Sdk.Dropbox\SecureFolderFS.Sdk.Dropbox.csproj", "{FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}" +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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -504,6 +510,54 @@ Global {A9219707-C494-4D3B-8123-43652707B516}.Release|x64.Build.0 = Release|Any CPU {A9219707-C494-4D3B-8123-43652707B516}.Release|x86.ActiveCfg = Release|Any CPU {A9219707-C494-4D3B-8123-43652707B516}.Release|x86.Build.0 = Release|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Debug|arm64.ActiveCfg = Debug|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Debug|arm64.Build.0 = Debug|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Debug|x64.ActiveCfg = Debug|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Debug|x64.Build.0 = Debug|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Debug|x86.ActiveCfg = Debug|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Debug|x86.Build.0 = Debug|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Release|Any CPU.Build.0 = Release|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Release|arm64.ActiveCfg = Release|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Release|arm64.Build.0 = Release|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Release|x64.ActiveCfg = Release|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Release|x64.Build.0 = Release|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Release|x86.ActiveCfg = Release|Any CPU + {85FE77EA-9F89-4F42-BD79-26C82F847DDC}.Release|x86.Build.0 = Release|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Debug|arm64.ActiveCfg = Debug|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Debug|arm64.Build.0 = Debug|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Debug|x64.Build.0 = Debug|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Debug|x86.Build.0 = Debug|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Release|Any CPU.Build.0 = Release|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Release|arm64.ActiveCfg = Release|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Release|arm64.Build.0 = Release|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Release|x64.ActiveCfg = Release|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Release|x64.Build.0 = Release|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Release|x86.ActiveCfg = Release|Any CPU + {FD52B782-4E07-41B2-8EA9-DE2347DEB9E2}.Release|x86.Build.0 = Release|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Debug|arm64.ActiveCfg = Debug|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Debug|arm64.Build.0 = Debug|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Debug|x64.ActiveCfg = Debug|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Debug|x64.Build.0 = Debug|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Debug|x86.ActiveCfg = Debug|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Debug|x86.Build.0 = Debug|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Release|Any CPU.Build.0 = Release|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Release|arm64.ActiveCfg = Release|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Release|arm64.Build.0 = Release|Any CPU + {E9D21865-C31B-49AD-B9CE-A8A9491789D5}.Release|x64.ActiveCfg = Release|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -539,6 +593,9 @@ Global {79C128B4-DD9D-4BAC-AA81-9BFAD02ECDD3} = {086CDAC6-2730-4F09-BA28-B41F737E6C4D} {F56308B3-01B8-489B-ABE2-69F35FC5A7DE} = {F2ACE2B7-1599-4769-8FF4-41FA03B25D26} {A9219707-C494-4D3B-8123-43652707B516} = {66BC1E2B-D99A-49E2-8B8F-EF7851493CB0} + {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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A1906FD8-BB54-4688-BC0F-9ED7532D2CB0} diff --git a/global.json b/global.json index 4d9e028b1..5cdd01fa3 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "msbuild-sdks": { - "Uno.Sdk": "6.0.96" + "Uno.Sdk": "6.5.31" } } \ No newline at end of file diff --git a/lib/nwebdav b/lib/nwebdav index fec42e99c..7bbb0741b 160000 --- a/lib/nwebdav +++ b/lib/nwebdav @@ -1 +1 @@ -Subproject commit fec42e99c8593f5de31ea3098d4b2f7cbaabcfce +Subproject commit 7bbb0741b675fd2b506d23726ce193143a22bcf7 diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv128.cs b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv128.cs index 89080e9be..0064e1f5f 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv128.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv128.cs @@ -26,7 +26,6 @@ public static AesSiv128 CreateInstance(ReadOnlySpan dekKey, ReadOnlySpan password, ReadOnlySpan salt, Span result) + public static void V2_DeriveKey(ReadOnlySpan password, ReadOnlySpan salt, Span result) { using var argon2id = new Konscious.Security.Cryptography.Argon2id(password.ToArray()); argon2id.Salt = salt.ToArray(); @@ -16,7 +15,7 @@ public static void Old_DeriveKey(ReadOnlySpan password, ReadOnlySpan argon2id.GetBytes(Constants.KeyTraits.ARGON2_KEK_LENGTH).CopyTo(result); } - public static void V3_DeriveKey(ReadOnlySpan password, ReadOnlySpan salt, Span result) + public static void DeriveKey(ReadOnlySpan password, ReadOnlySpan salt, Span result) { using var argon2id = new Konscious.Security.Cryptography.Argon2id(password.ToArray()); argon2id.Salt = salt.ToArray(); diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Constants.cs b/src/Core/SecureFolderFS.Core.Cryptography/Constants.cs index 8ea44e7b8..ac4fc3eeb 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Constants.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Constants.cs @@ -9,8 +9,11 @@ public static class KeyTraits public const int DEK_KEY_LENGTH = 32; public const int MAC_KEY_LENGTH = 32; public const int ARGON2_KEK_LENGTH = 32; - public const int CHALLENGE_KEY_PART_LENGTH = 128; - public const int ECIES_SHA256_AESGCM_STDX963_KEY_LENGTH = 32; + public const int CHALLENGE_KEY_PART_LENGTH_32 = 32; + public const int CHALLENGE_KEY_PART_LENGTH_64 = 64; + public const int CHALLENGE_KEY_PART_LENGTH_128 = 128; + public const int ECIES_SHA256_AESGCM_STDX963_KEY_LENGTH = CHALLENGE_KEY_PART_LENGTH_32; + public const int HMAC_SHA1_HASH_LENGTH = 20; } public static class CipherId diff --git a/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/AesCtrHmacContentCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/AesCtrHmacContentCrypt.cs index 729aa0e90..7199d6df2 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/AesCtrHmacContentCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/AesCtrHmacContentCrypt.cs @@ -3,6 +3,7 @@ using System; using System.Runtime.CompilerServices; using System.Security.Cryptography; +using SecureFolderFS.Shared.ComponentModel; using static SecureFolderFS.Core.Cryptography.Constants.Crypto.Chunks.AesCtrHmac; using static SecureFolderFS.Core.Cryptography.Extensions.ContentCryptExtensions.AesCtrHmacContentExtensions; using static SecureFolderFS.Core.Cryptography.Extensions.HeaderCryptExtensions.AesCtrHmacHeaderExtensions; @@ -12,7 +13,7 @@ namespace SecureFolderFS.Core.Cryptography.ContentCrypt /// internal sealed class AesCtrHmacContentCrypt : BaseContentCrypt { - private readonly SecretKey _macKey; + private readonly IKeyUsage _macKey; /// public override int ChunkPlaintextSize { get; } = CHUNK_PLAINTEXT_SIZE; @@ -23,16 +24,17 @@ internal sealed class AesCtrHmacContentCrypt : BaseContentCrypt /// public override int ChunkFirstReservedSize { get; } = CHUNK_NONCE_SIZE; - public AesCtrHmacContentCrypt(SecretKey macKey) + public AesCtrHmacContentCrypt(IKeyUsage macKey) { _macKey = macKey; } /// - public override void EncryptChunk(ReadOnlySpan plaintextChunk, long chunkNumber, ReadOnlySpan header, Span ciphertextChunk) + [SkipLocalsInit] + public override unsafe void EncryptChunk(ReadOnlySpan plaintextChunk, long chunkNumber, ReadOnlySpan header, Span ciphertextChunk) { // Chunk nonce - secureRandom.GetBytes(ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE)); + RandomNumberGenerator.Fill(ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE)); // Encrypt AesCtr128.Encrypt( @@ -41,34 +43,75 @@ public override void EncryptChunk(ReadOnlySpan plaintextChunk, long chunkN ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE), ciphertextChunk.Slice(CHUNK_NONCE_SIZE, plaintextChunk.Length)); - // Calculate MAC - CalculateChunkMac( - header.GetHeaderNonce(), - ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE), - ciphertextChunk.Slice(CHUNK_NONCE_SIZE, plaintextChunk.Length), - chunkNumber, - ciphertextChunk.Slice(CHUNK_NONCE_SIZE + plaintextChunk.Length, CHUNK_MAC_SIZE)); + // Calculate MAC using UseKey pattern + fixed (byte* headerPtr = header) + fixed (byte* ciphertextPtr = ciphertextChunk) + { + var state = ( + headerPtr: (nint)headerPtr, + headerLen: header.Length, + ctPtr: (nint)ciphertextPtr, + ctLen: ciphertextChunk.Length, + ptLen: plaintextChunk.Length, + chunkNumber + ); + + _macKey.UseKey(state, static (macKey, s) => + { + var hdr = new ReadOnlySpan((byte*)s.headerPtr, s.headerLen); + var ct = new Span((byte*)s.ctPtr, s.ctLen); + + CalculateChunkMacStatic( + macKey, + hdr.GetHeaderNonce(), + ct.Slice(0, CHUNK_NONCE_SIZE), + ct.Slice(CHUNK_NONCE_SIZE, s.ptLen), + s.chunkNumber, + ct.Slice(CHUNK_NONCE_SIZE + s.ptLen, CHUNK_MAC_SIZE)); + }); + } } /// [SkipLocalsInit] - public override bool DecryptChunk(ReadOnlySpan ciphertextChunk, long chunkNumber, - ReadOnlySpan header, Span plaintextChunk) + public override unsafe bool DecryptChunk(ReadOnlySpan ciphertextChunk, long chunkNumber, ReadOnlySpan header, Span plaintextChunk) { - // Allocate byte* for MAC - Span mac = stackalloc byte[CHUNK_MAC_SIZE]; - - // Calculate MAC - CalculateChunkMac( - header.GetHeaderNonce(), - ciphertextChunk.GetChunkNonce(), - ciphertextChunk.GetChunkPayload(), - chunkNumber, - mac); - - // Check MAC - if (!mac.SequenceEqual(ciphertextChunk.GetChunkMac())) - return false; + // Verify MAC using UseKey pattern + fixed (byte* headerPtr = header) + fixed (byte* ciphertextPtr = ciphertextChunk) + { + var state = ( + headerPtr: (nint)headerPtr, + headerLen: header.Length, + ctPtr: (nint)ciphertextPtr, + ctLen: ciphertextChunk.Length, + chunkNumber + ); + + var macValid = _macKey.UseKey(state, static (macKey, s) => + { + var hdr = new ReadOnlySpan((byte*)s.headerPtr, s.headerLen); + var ct = new ReadOnlySpan((byte*)s.ctPtr, s.ctLen); + + // Allocate byte* for MAC + Span mac = stackalloc byte[CHUNK_MAC_SIZE]; + + // Calculate MAC + CalculateChunkMacStatic( + macKey, + hdr.GetHeaderNonce(), + ct.GetChunkNonce(), + ct.GetChunkPayload(), + s.chunkNumber, + mac); + + // Check MAC using constant-time comparison to prevent timing attacks + return CryptographicOperations.FixedTimeEquals(mac, ct.GetChunkMac()); + }); + + if (!macValid) + return false; + } // Decrypt AesCtr128.Decrypt( @@ -81,7 +124,7 @@ public override bool DecryptChunk(ReadOnlySpan ciphertextChunk, long chunk } [SkipLocalsInit] - private void CalculateChunkMac(ReadOnlySpan headerNonce, ReadOnlySpan chunkNonce, ReadOnlySpan ciphertextPayload, long chunkNumber, Span chunkMac) + private static void CalculateChunkMacStatic(ReadOnlySpan macKey, ReadOnlySpan headerNonce, ReadOnlySpan chunkNonce, ReadOnlySpan ciphertextPayload, long chunkNumber, Span chunkMac) { // Convert long to byte array Span beChunkNumber = stackalloc byte[sizeof(long)]; @@ -92,12 +135,12 @@ private void CalculateChunkMac(ReadOnlySpan headerNonce, ReadOnlySpan plaintextChunk, long chunkNumber, ReadOnlySpan header, Span ciphertextChunk) { // Chunk nonce - secureRandom.GetBytes(ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE)); + RandomNumberGenerator.Fill(ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE)); // Big Endian chunk number and file header nonce Span associatedData = stackalloc byte[sizeof(long) + HEADER_NONCE_SIZE]; - CryptHelpers.FillAssociatedDataBe(associatedData, header.GetHeaderNonce(), chunkNumber); + CryptHelpers.FillAssociatedDataBigEndian(associatedData, header.GetHeaderNonce(), chunkNumber); // Encrypt AesGcm128.Encrypt( @@ -48,7 +49,7 @@ public override bool DecryptChunk(ReadOnlySpan ciphertextChunk, long chunk { // Big Endian chunk number and file header nonce Span associatedData = stackalloc byte[sizeof(long) + HEADER_NONCE_SIZE]; - CryptHelpers.FillAssociatedDataBe(associatedData, header.GetHeaderNonce(), chunkNumber); + CryptHelpers.FillAssociatedDataBigEndian(associatedData, header.GetHeaderNonce(), chunkNumber); // Decrypt return AesGcm128.TryDecrypt( diff --git a/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/BaseContentCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/BaseContentCrypt.cs index d2c8eda08..477d90fbc 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/BaseContentCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/BaseContentCrypt.cs @@ -6,8 +6,6 @@ namespace SecureFolderFS.Core.Cryptography.ContentCrypt /// internal abstract class BaseContentCrypt : IContentCrypt { - protected readonly RandomNumberGenerator secureRandom; - /// public abstract int ChunkPlaintextSize { get; } @@ -17,11 +15,6 @@ internal abstract class BaseContentCrypt : IContentCrypt /// public abstract int ChunkFirstReservedSize { get; } - protected BaseContentCrypt() - { - secureRandom = RandomNumberGenerator.Create(); - } - /// public abstract void EncryptChunk(ReadOnlySpan plaintextChunk, long chunkNumber, ReadOnlySpan header, Span ciphertextChunk); @@ -61,7 +54,6 @@ public virtual long CalculatePlaintextSize(long ciphertextSize) /// public virtual void Dispose() { - secureRandom.Dispose(); } } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/XChaChaContentCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/XChaChaContentCrypt.cs index ced6add47..2a7056d7b 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/XChaChaContentCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/ContentCrypt/XChaChaContentCrypt.cs @@ -2,6 +2,7 @@ using SecureFolderFS.Core.Cryptography.Helpers; using System; using System.Runtime.CompilerServices; +using System.Security.Cryptography; using static SecureFolderFS.Core.Cryptography.Constants.Crypto.Chunks.XChaCha20Poly1305; using static SecureFolderFS.Core.Cryptography.Constants.Crypto.Headers.XChaCha20Poly1305; using static SecureFolderFS.Core.Cryptography.Extensions.ContentCryptExtensions.XChaChaContentExtensions; @@ -26,11 +27,11 @@ internal sealed class XChaChaContentCrypt : BaseContentCrypt public override void EncryptChunk(ReadOnlySpan plaintextChunk, long chunkNumber, ReadOnlySpan header, Span ciphertextChunk) { // Chunk nonce - secureRandom.GetBytes(ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE)); + RandomNumberGenerator.Fill(ciphertextChunk.Slice(0, CHUNK_NONCE_SIZE)); // Big Endian chunk number and file header nonce Span associatedData = stackalloc byte[sizeof(long) + HEADER_NONCE_SIZE]; - CryptHelpers.FillAssociatedDataBe(associatedData, header.GetHeaderNonce(), chunkNumber); + CryptHelpers.FillAssociatedDataBigEndian(associatedData, header.GetHeaderNonce(), chunkNumber); // Encrypt XChaCha20Poly1305.Encrypt( @@ -48,7 +49,7 @@ public override unsafe bool DecryptChunk(ReadOnlySpan ciphertextChunk, lon { // Big Endian chunk number and file header nonce Span associatedData = stackalloc byte[sizeof(long) + HEADER_NONCE_SIZE]; - CryptHelpers.FillAssociatedDataBe(associatedData, header.GetHeaderNonce(), chunkNumber); + CryptHelpers.FillAssociatedDataBigEndian(associatedData, header.GetHeaderNonce(), chunkNumber); // Decrypt return XChaCha20Poly1305.Decrypt( diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Extensions/KeyExtensions.cs b/src/Core/SecureFolderFS.Core.Cryptography/Extensions/KeyExtensions.cs new file mode 100644 index 000000000..93f556697 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/Extensions/KeyExtensions.cs @@ -0,0 +1,40 @@ +using System; +using SecureFolderFS.Core.Cryptography.SecureStore; +using SecureFolderFS.Shared.ComponentModel; + +namespace SecureFolderFS.Core.Cryptography.Extensions +{ + /// + /// Provides extension methods for the class. + /// + public static class KeyExtensions + { + /// + /// Creates a copy of the specified instance if it is cloneable. + /// + /// The original to copy. + /// A new copy of the . + public static TKey CreateCopy(this TKey originalKey) + where TKey : IKeyUsage + { + if (originalKey is ICloneable cloneableKey) + return (TKey)cloneableKey.Clone(); + + throw new NotSupportedException("The provided key instance is not cloneable."); + } + + /// + /// Creates a unique copy of the specified and disposes the original key. + /// + /// The original to copy. + /// A new copy of the key. + public static TKey CreateUniqueCopy(this TKey originalKey) + where TKey : IKeyUsage + { + var copiedKey = originalKey.CreateCopy(); + originalKey.Dispose(); + + return copiedKey; + } + } +} diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Extensions/SecretKeyExtensions.cs b/src/Core/SecureFolderFS.Core.Cryptography/Extensions/SecretKeyExtensions.cs deleted file mode 100644 index ec0a3a8b2..000000000 --- a/src/Core/SecureFolderFS.Core.Cryptography/Extensions/SecretKeyExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -using SecureFolderFS.Core.Cryptography.SecureStore; - -namespace SecureFolderFS.Core.Cryptography.Extensions -{ - /// - /// Provides extension methods for the class. - /// - public static class SecretKeyExtensions - { - /// - /// Creates a unique copy of the specified and disposes the original key. - /// - /// The original to copy. - /// A new copy of the . - public static SecretKey CreateUniqueCopy(this SecretKey originalKey) - { - var copiedKey = originalKey.CreateCopy(); - originalKey.Dispose(); - - return copiedKey; - } - } -} diff --git a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesCtrHmacHeaderCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesCtrHmacHeaderCrypt.cs index aa2944242..f22bc458d 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesCtrHmacHeaderCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesCtrHmacHeaderCrypt.cs @@ -26,66 +26,108 @@ public AesCtrHmacHeaderCrypt(KeyPair keyPair) public override void CreateHeader(Span plaintextHeader) { // Nonce - secureRandom.GetNonZeroBytes(plaintextHeader.Slice(0, HEADER_NONCE_SIZE)); + RandomNumberGenerator.Fill(plaintextHeader.Slice(0, HEADER_NONCE_SIZE)); // Content key - secureRandom.GetBytes(plaintextHeader.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE)); + RandomNumberGenerator.Fill(plaintextHeader.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE)); } /// - public override void EncryptHeader(ReadOnlySpan plaintextHeader, Span ciphertextHeader) + public override unsafe void EncryptHeader(ReadOnlySpan plaintextHeader, Span ciphertextHeader) { // Nonce plaintextHeader.Slice(0, HEADER_NONCE_SIZE).CopyTo(ciphertextHeader); - // Encrypt - AesCtr128.Encrypt( - plaintextHeader.GetHeaderContentKey(), - DekKey, - plaintextHeader.GetHeaderNonce(), - ciphertextHeader.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE)); - - // Calculate MAC - CalculateHeaderMac( - plaintextHeader.GetHeaderNonce(), - ciphertextHeader.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE), - ciphertextHeader.Slice(plaintextHeader.Length)); // plaintextHeader.Length already includes HEADER_NONCE_SIZE + // Use unsafe pointers to pass span data through the UseKey callback + fixed (byte* plaintextPtr = plaintextHeader) + fixed (byte* ciphertextPtr = ciphertextHeader) + { + var state = (ptPtr: (nint)plaintextPtr, ptLen: plaintextHeader.Length, ctPtr: (nint)ciphertextPtr, ctLen: ciphertextHeader.Length); + + // Encrypt with DekKey + DekKey.UseKey(state, static (dekKey, s) => + { + var pt = new ReadOnlySpan((byte*)s.ptPtr, s.ptLen); + var ct = new Span((byte*)s.ctPtr, s.ctLen); + + AesCtr128.Encrypt( + pt.GetHeaderContentKey(), + dekKey, + pt.GetHeaderNonce(), + ct.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE)); + }); + + // Calculate MAC with MacKey + MacKey.UseKey(state, static (macKey, s) => + { + var pt = new ReadOnlySpan((byte*)s.ptPtr, s.ptLen); + var ct = new Span((byte*)s.ctPtr, s.ctLen); + + CalculateHeaderMacInternal( + macKey, + pt.GetHeaderNonce(), + ct.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE), + ct.Slice(pt.Length)); // plaintextHeader.Length already includes HEADER_NONCE_SIZE + }); + } } /// [SkipLocalsInit] - public override bool DecryptHeader(ReadOnlySpan ciphertextHeader, Span plaintextHeader) + public override unsafe bool DecryptHeader(ReadOnlySpan ciphertextHeader, Span plaintextHeader) { - // Allocate byte* for MAC - Span mac = stackalloc byte[HEADER_MAC_SIZE]; - - // Calculate MAC - CalculateHeaderMac( - ciphertextHeader.GetHeaderNonce(), - ciphertextHeader.GetHeaderContentKey(), - mac); - - // Check MAC - if (!mac.SequenceEqual(ciphertextHeader.GetHeaderMac())) - return false; - - // Nonce - ciphertextHeader.GetHeaderNonce().CopyTo(plaintextHeader); - - // Decrypt - AesCtr128.Decrypt( - ciphertextHeader.GetHeaderContentKey(), - DekKey, - ciphertextHeader.GetHeaderNonce(), - plaintextHeader.Slice(HEADER_NONCE_SIZE)); - - return true; + // Use unsafe pointers to pass span data through the UseKey callback + fixed (byte* ciphertextPtr = ciphertextHeader) + fixed (byte* plaintextPtr = plaintextHeader) + { + var state = (ctPtr: (nint)ciphertextPtr, ctLen: ciphertextHeader.Length, ptPtr: (nint)plaintextPtr, ptLen: plaintextHeader.Length); + + // Verify MAC with MacKey + var macValid = MacKey.UseKey(state, static (macKey, s) => + { + var ct = new ReadOnlySpan((byte*)s.ctPtr, s.ctLen); + + // Allocate byte* for MAC + Span mac = stackalloc byte[HEADER_MAC_SIZE]; + + // Calculate MAC + CalculateHeaderMacInternal( + macKey, + ct.GetHeaderNonce(), + ct.GetHeaderContentKey(), + mac); + + // Check MAC using constant-time comparison to prevent timing attacks + return CryptographicOperations.FixedTimeEquals(mac, ct.GetHeaderMac()); + }); + + if (!macValid) + return false; + + // Nonce + ciphertextHeader.GetHeaderNonce().CopyTo(plaintextHeader); + + // Decrypt with DekKey + DekKey.UseKey(state, static (dekKey, s) => + { + var ct = new ReadOnlySpan((byte*)s.ctPtr, s.ctLen); + var pt = new Span((byte*)s.ptPtr, s.ptLen); + + AesCtr128.Decrypt( + ct.GetHeaderContentKey(), + dekKey, + ct.GetHeaderNonce(), + pt.Slice(HEADER_NONCE_SIZE)); + }); + + return true; + } } - private void CalculateHeaderMac(ReadOnlySpan headerNonce, ReadOnlySpan ciphertextPayload, Span headerMac) + private static void CalculateHeaderMacInternal(ReadOnlySpan macKey, ReadOnlySpan headerNonce, ReadOnlySpan ciphertextPayload, Span headerMac) { // Initialize HMAC - using var hmacSha256 = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, MacKey); + using var hmacSha256 = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, macKey); hmacSha256.AppendData(headerNonce); // headerNonce hmacSha256.AppendData(ciphertextPayload); // ciphertextPayload diff --git a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesGcmHeaderCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesGcmHeaderCrypt.cs index a8d0040d4..23b1f16dd 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesGcmHeaderCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/AesGcmHeaderCrypt.cs @@ -1,6 +1,7 @@ using SecureFolderFS.Core.Cryptography.Cipher; using SecureFolderFS.Core.Cryptography.SecureStore; using System; +using System.Security.Cryptography; using static SecureFolderFS.Core.Cryptography.Constants.Crypto.Headers.AesGcm; using static SecureFolderFS.Core.Cryptography.Extensions.HeaderCryptExtensions.AesGcmHeaderExtensions; @@ -24,42 +25,66 @@ public AesGcmHeaderCrypt(KeyPair keyPair) public override void CreateHeader(Span plaintextHeader) { // Nonce - secureRandom.GetNonZeroBytes(plaintextHeader.Slice(0, HEADER_NONCE_SIZE)); + RandomNumberGenerator.Fill(plaintextHeader.Slice(0, HEADER_NONCE_SIZE)); // Content key - secureRandom.GetBytes(plaintextHeader.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE)); + RandomNumberGenerator.Fill(plaintextHeader.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE)); } /// - public override void EncryptHeader(ReadOnlySpan plaintextHeader, Span ciphertextHeader) + public override unsafe void EncryptHeader(ReadOnlySpan plaintextHeader, Span ciphertextHeader) { // Nonce plaintextHeader.GetHeaderNonce().CopyTo(ciphertextHeader); - // Encrypt - AesGcm128.Encrypt( - plaintextHeader.GetHeaderContentKey(), - DekKey, - plaintextHeader.GetHeaderNonce(), - ciphertextHeader.GetHeaderTag(), - ciphertextHeader.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE), - default); + // Use unsafe pointers to pass span data through the UseKey callback + fixed (byte* plaintextPtr = plaintextHeader) + fixed (byte* ciphertextPtr = ciphertextHeader) + { + var state = (ptPtr: (nint)plaintextPtr, ptLen: plaintextHeader.Length, ctPtr: (nint)ciphertextPtr, ctLen: ciphertextHeader.Length); + DekKey.UseKey(state, static (dekKey, s) => + { + var pt = new ReadOnlySpan((byte*)s.ptPtr, s.ptLen); + var ct = new Span((byte*)s.ctPtr, s.ctLen); + + // Encrypt + AesGcm128.Encrypt( + pt.GetHeaderContentKey(), + dekKey, + pt.GetHeaderNonce(), + ct.GetHeaderTag(), + ct.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE), + ReadOnlySpan.Empty); + }); + } } /// - public override bool DecryptHeader(ReadOnlySpan ciphertextHeader, Span plaintextHeader) + public override unsafe bool DecryptHeader(ReadOnlySpan ciphertextHeader, Span plaintextHeader) { // Nonce ciphertextHeader.GetHeaderNonce().CopyTo(plaintextHeader); - // Decrypt - return AesGcm128.TryDecrypt( - ciphertextHeader.GetHeaderContentKey(), - DekKey, - ciphertextHeader.GetHeaderNonce(), - ciphertextHeader.GetHeaderTag(), - plaintextHeader.Slice(HEADER_NONCE_SIZE), - default); + // Use unsafe pointers to pass span data through the UseKey callback + fixed (byte* ciphertextPtr = ciphertextHeader) + fixed (byte* plaintextPtr = plaintextHeader) + { + var state = (ctPtr: (nint)ciphertextPtr, ctLen: ciphertextHeader.Length, ptPtr: (nint)plaintextPtr, ptLen: plaintextHeader.Length); + return DekKey.UseKey(state, static (dekKey, s) => + { + var ct = new ReadOnlySpan((byte*)s.ctPtr, s.ctLen); + var pt = new Span((byte*)s.ptPtr, s.ptLen); + + // Decrypt + return AesGcm128.TryDecrypt( + ct.GetHeaderContentKey(), + dekKey, + ct.GetHeaderNonce(), + ct.GetHeaderTag(), + pt.Slice(HEADER_NONCE_SIZE), + ReadOnlySpan.Empty); + }); + } } } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/BaseHeaderCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/BaseHeaderCrypt.cs index 9b7a43cf2..a1e407d93 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/BaseHeaderCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/BaseHeaderCrypt.cs @@ -1,18 +1,18 @@ using SecureFolderFS.Core.Cryptography.SecureStore; using System; using System.Security.Cryptography; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Core.Cryptography.HeaderCrypt { /// internal abstract class BaseHeaderCrypt : IHeaderCrypt { - protected readonly KeyPair keyPair; - protected readonly RandomNumberGenerator secureRandom; + private readonly KeyPair _keyPair; - protected SecretKey DekKey => keyPair.DekKey; + protected IKeyUsage DekKey => _keyPair.DekKey; - protected SecretKey MacKey => keyPair.MacKey; + protected IKeyUsage MacKey => _keyPair.MacKey; /// public abstract int HeaderCiphertextSize { get; } @@ -22,8 +22,7 @@ internal abstract class BaseHeaderCrypt : IHeaderCrypt protected BaseHeaderCrypt(KeyPair keyPair) { - this.keyPair = keyPair; - this.secureRandom = RandomNumberGenerator.Create(); + _keyPair = keyPair; } /// @@ -38,7 +37,6 @@ protected BaseHeaderCrypt(KeyPair keyPair) /// public virtual void Dispose() { - secureRandom.Dispose(); } } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/XChaChaHeaderCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/XChaChaHeaderCrypt.cs index ec9bb2f32..9fd9ecd18 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/XChaChaHeaderCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/HeaderCrypt/XChaChaHeaderCrypt.cs @@ -1,6 +1,7 @@ using SecureFolderFS.Core.Cryptography.Cipher; using SecureFolderFS.Core.Cryptography.SecureStore; using System; +using System.Security.Cryptography; using static SecureFolderFS.Core.Cryptography.Constants.Crypto.Headers.XChaCha20Poly1305; using static SecureFolderFS.Core.Cryptography.Extensions.HeaderCryptExtensions.XChaChaHeaderExtensions; @@ -24,40 +25,64 @@ public XChaChaHeaderCrypt(KeyPair keyPair) public override void CreateHeader(Span plaintextHeader) { // Nonce - secureRandom.GetNonZeroBytes(plaintextHeader.Slice(0, HEADER_NONCE_SIZE)); + RandomNumberGenerator.Fill(plaintextHeader.Slice(0, HEADER_NONCE_SIZE)); // Content key - secureRandom.GetBytes(plaintextHeader.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE)); + RandomNumberGenerator.Fill(plaintextHeader.Slice(HEADER_NONCE_SIZE, HEADER_CONTENTKEY_SIZE)); } /// - public override void EncryptHeader(ReadOnlySpan plaintextHeader, Span ciphertextHeader) + public override unsafe void EncryptHeader(ReadOnlySpan plaintextHeader, Span ciphertextHeader) { // Nonce plaintextHeader.GetHeaderNonce().CopyTo(ciphertextHeader); - // Encrypt - XChaCha20Poly1305.Encrypt( - plaintextHeader.GetHeaderContentKey(), - DekKey, - plaintextHeader.GetHeaderNonce(), - ciphertextHeader.SkipNonce(), - default); + // Use unsafe pointers to pass span data through the UseKey callback + fixed (byte* plaintextPtr = plaintextHeader) + fixed (byte* ciphertextPtr = ciphertextHeader) + { + var state = (ptPtr: (nint)plaintextPtr, ptLen: plaintextHeader.Length, ctPtr: (nint)ciphertextPtr, ctLen: ciphertextHeader.Length); + DekKey.UseKey(state, static (dekKey, s) => + { + var pt = new ReadOnlySpan((byte*)s.ptPtr, s.ptLen); + var ct = new Span((byte*)s.ctPtr, s.ctLen); + + // Encrypt + XChaCha20Poly1305.Encrypt( + pt.GetHeaderContentKey(), + dekKey, + pt.GetHeaderNonce(), + ct.SkipNonce(), + ReadOnlySpan.Empty); + }); + } } /// - public override bool DecryptHeader(ReadOnlySpan ciphertextHeader, Span plaintextHeader) + public override unsafe bool DecryptHeader(ReadOnlySpan ciphertextHeader, Span plaintextHeader) { // Nonce ciphertextHeader.GetHeaderNonce().CopyTo(plaintextHeader); - // Decrypt - return XChaCha20Poly1305.Decrypt( - ciphertextHeader.SkipNonce(), - DekKey, - ciphertextHeader.GetHeaderNonce(), - plaintextHeader.SkipNonce(), - default); + // Use unsafe pointers to pass span data through the UseKey callback + fixed (byte* ciphertextPtr = ciphertextHeader) + fixed (byte* plaintextPtr = plaintextHeader) + { + var state = (ctPtr: (nint)ciphertextPtr, ctLen: ciphertextHeader.Length, ptPtr: (nint)plaintextPtr, ptLen: plaintextHeader.Length); + return DekKey.UseKey(state, static (dekKey, s) => + { + var ct = new ReadOnlySpan((byte*)s.ctPtr, s.ctLen); + var pt = new Span((byte*)s.ptPtr, s.ptLen); + + // Decrypt + return XChaCha20Poly1305.Decrypt( + ct.SkipNonce(), + dekKey, + ct.GetHeaderNonce(), + pt.SkipNonce(), + ReadOnlySpan.Empty); + }); + } } } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Helpers/CryptHelpers.cs b/src/Core/SecureFolderFS.Core.Cryptography/Helpers/CryptHelpers.cs index 2bacbf89b..66852b0d7 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Helpers/CryptHelpers.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Helpers/CryptHelpers.cs @@ -1,12 +1,35 @@ using System; using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using SecureFolderFS.Core.Cryptography.SecureStore; +using SecureFolderFS.Shared.ComponentModel; using static SecureFolderFS.Core.Cryptography.Constants.CipherId; namespace SecureFolderFS.Core.Cryptography.Helpers { public static class CryptHelpers { - internal static void FillAssociatedDataBe(Span associatedData, ReadOnlySpan headerNonce, long chunkNumber) + public static IKeyBytes GenerateChallenge(string vaultId, int challengeSize = Constants.KeyTraits.CHALLENGE_KEY_PART_LENGTH_128) + { + var encodedVaultIdLength = Encoding.ASCII.GetByteCount(vaultId); + var challenge = new byte[challengeSize + encodedVaultIdLength]; + + // Fill the first CHALLENGE_KEY_PART_LENGTH bytes with secure random data + RandomNumberGenerator.Fill(challenge.AsSpan(0, challengeSize)); + + // Fill the remaining bytes with the ID + // By using ASCII encoding we get 1:1 byte to char ratio which allows us + // to use the length of the string ID as part of the SecretKey length above + var written = Encoding.ASCII.GetBytes(vaultId, challenge.AsSpan(challengeSize)); + if (written != encodedVaultIdLength) + throw new FormatException("The allocated buffer and vault ID written bytes amount were different."); + + // Return a protected key + return ManagedKey.TakeOwnership(challenge); + } + + internal static void FillAssociatedDataBigEndian(Span associatedData, ReadOnlySpan headerNonce, long chunkNumber) { // Set first 8B of chunk number to associatedData Unsafe.As(ref associatedData[0]) = chunkNumber; diff --git a/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs index 441848bba..2cef11dac 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs @@ -1,21 +1,28 @@ using SecureFolderFS.Core.Cryptography.SecureStore; using System; using System.Security.Cryptography; +using SecureFolderFS.Core.Cryptography.Cipher; namespace SecureFolderFS.Core.Cryptography.NameCrypt { /// internal sealed class AesSivNameCrypt : BaseNameCrypt { + private readonly AesSiv128 _aesSiv128; + public AesSivNameCrypt(KeyPair keyPair, string fileNameEncodingId) - : base(keyPair, fileNameEncodingId) + : base(fileNameEncodingId) { + _aesSiv128 = keyPair.UseKeys((dekKey, macKey) => + { + return AesSiv128.CreateInstance(dekKey.ToArray(), macKey.ToArray()); // Note: AesSiv128 requires a byte[] key. + }); } /// protected override byte[] EncryptFileName(ReadOnlySpan plaintextFileNameBuffer, ReadOnlySpan directoryId) { - return aesSiv128.Encrypt(plaintextFileNameBuffer, directoryId); + return _aesSiv128.Encrypt(plaintextFileNameBuffer, directoryId); } /// @@ -23,12 +30,18 @@ protected override byte[] EncryptFileName(ReadOnlySpan plaintextFileNameBu { try { - return aesSiv128.Decrypt(ciphertextFileNameBuffer, directoryId); + return _aesSiv128.Decrypt(ciphertextFileNameBuffer, directoryId); } catch (CryptographicException) { return null; } } + + /// + public override void Dispose() + { + _aesSiv128.Dispose(); + } } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/BaseNameCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/BaseNameCrypt.cs index 04cf4e3dd..66a37b7b9 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/BaseNameCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/BaseNameCrypt.cs @@ -3,20 +3,16 @@ using System.Runtime.CompilerServices; using System.Text; using Lex4K; -using SecureFolderFS.Core.Cryptography.Cipher; -using SecureFolderFS.Core.Cryptography.SecureStore; namespace SecureFolderFS.Core.Cryptography.NameCrypt { /// internal abstract class BaseNameCrypt : INameCrypt { - protected readonly AesSiv128 aesSiv128; protected readonly string fileNameEncodingId; - protected BaseNameCrypt(KeyPair keyPair, string fileNameEncodingId) + protected BaseNameCrypt(string fileNameEncodingId) { - this.aesSiv128 = AesSiv128.CreateInstance(keyPair.DekKey, keyPair.MacKey); this.fileNameEncodingId = fileNameEncodingId; } @@ -75,9 +71,6 @@ public virtual string EncryptName(ReadOnlySpan plaintextName, ReadOnlySpan protected abstract byte[]? DecryptFileName(ReadOnlySpan ciphertextFileNameBuffer, ReadOnlySpan directoryId); /// - public virtual void Dispose() - { - aesSiv128.Dispose(); - } + public abstract void Dispose(); } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj b/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj index 37ceac601..f9e114133 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj +++ b/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/KeyPair.cs b/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/KeyPair.cs index 3b885f5eb..0547aecf8 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/KeyPair.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/KeyPair.cs @@ -1,5 +1,6 @@ -using SecureFolderFS.Core.Cryptography.Extensions; -using System; +using System; +using SecureFolderFS.Core.Cryptography.Extensions; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Core.Cryptography.SecureStore { @@ -11,19 +12,112 @@ public sealed class KeyPair : IDisposable /// /// Gets the Data Encryption Key (DEK). /// - public SecretKey DekKey { get; } + public IKeyUsage DekKey { get; } /// /// Gets the Message Authentication Code (MAC) key. /// - public SecretKey MacKey { get; } + public IKeyUsage MacKey { get; } - private KeyPair(SecretKey dekKey, SecretKey macKey) + private KeyPair(IKeyUsage dekKey, IKeyUsage macKey) { DekKey = dekKey; MacKey = macKey; } + /// + /// Allows secure access to the DEK and MAC keys through the provided delegate. + /// + /// A delegate that processes the DEK key and MAC key as read-only spans of bytes. + /// + /// The method securely provides access to the underlying DEK and MAC keys by passing them as read-only spans to the supplied delegate. + /// Ensure the provided delegate does not retain references to the keys outside its execution scope. + /// + public unsafe void UseKeys(Action, ReadOnlySpan> keyAction) + { + DekKey.UseKey(dekKey => + { + fixed (byte* dekPtr = dekKey) + { + var state = (dekPtr: (nint)dekPtr, dekLen: dekKey.Length); + MacKey.UseKey(state, (mac, s) => + { + var dek = new ReadOnlySpan((byte*)s.dekPtr, s.dekLen); + keyAction(dek, mac); + }); + } + }); + } + + /// + public unsafe void UseKeys(TState state, Action, ReadOnlySpan, TState> keyAction) + { + DekKey.UseKey(dekKey => + { + fixed (byte* dekPtr = dekKey) + { + var innerState = (dekPtr: (nint)dekPtr, dekLen: dekKey.Length, outerState: state, action: keyAction); + MacKey.UseKey(innerState, (mac, s) => + { + var dek = new ReadOnlySpan((byte*)s.dekPtr, s.dekLen); + s.action(dek, mac, s.outerState); + }); + } + }); + } + + /// + /// Allows secure execution of a function that processes the DEK and MAC keys as read-only spans of bytes and returns a result. + /// + /// A delegate that processes the DEK key and MAC key as read-only spans of bytes. + /// The type of the result produced by the function. + /// The result produced by executing the provided function with the DEK and MAC keys. + /// + /// The method securely provides access to the underlying DEK and MAC keys by passing them as read-only spans to the supplied delegate. + /// Ensure the provided delegate does not retain references to the keys outside its execution scope. + /// + public unsafe TResult UseKeys(Func, ReadOnlySpan, TResult> keyAction) + { + return DekKey.UseKey(dekKey => + { + fixed (byte* dekPtr = dekKey) + { + var state = (dekPtr: (nint)dekPtr, dekLen: dekKey.Length, action: keyAction); + return MacKey.UseKey(state, (mac, s) => + { + var dek = new ReadOnlySpan((byte*)s.dekPtr, s.dekLen); + return s.action(dek, mac); + }); + } + }); + } + + /// + public unsafe TResult UseKeys(TState state, Func, ReadOnlySpan, TState, TResult> keyAction) + { + return DekKey.UseKey(dekKey => + { + fixed (byte* dekPtr = dekKey) + { + var innerState = (dekPtr: (nint)dekPtr, dekLen: dekKey.Length, outerState: state, action: keyAction); + return MacKey.UseKey(innerState, (mac, s) => + { + var dek = new ReadOnlySpan((byte*)s.dekPtr, s.dekLen); + return s.action(dek, mac, s.outerState); + }); + } + }); + } + + /// + /// Creates a new copy of the current instance, including separate copies of the contained DEK and MAC keys. + /// + /// A new instance with copied DEK and MAC keys. + public KeyPair CreateCopy() + { + return new(DekKey.CreateCopy(), MacKey.CreateCopy()); + } + /// /// Imports the specified DEK and MAC keys, creating unique copies of them and disposing the original instances. /// @@ -35,7 +129,7 @@ private KeyPair(SecretKey dekKey, SecretKey macKey) /// Instead, use and instances. /// /// A new instance of the class with the imported keys. - public static KeyPair ImportKeys(SecretKey dekKeyToDestroy, SecretKey macKeyToDestroy) + public static KeyPair ImportKeys(IKeyUsage dekKeyToDestroy, IKeyUsage macKeyToDestroy) { return new(dekKeyToDestroy.CreateUniqueCopy(), macKeyToDestroy.CreateUniqueCopy()); } @@ -43,18 +137,21 @@ public static KeyPair ImportKeys(SecretKey dekKeyToDestroy, SecretKey macKeyToDe /// public override string ToString() { - return $"{Convert.ToBase64String(DekKey)}{Constants.KeyTraits.KEY_TEXT_SEPARATOR}{Convert.ToBase64String(MacKey)}"; + return UseKeys((dekKey, macKey) => + { + return $"{Convert.ToBase64String(dekKey)}{Constants.KeyTraits.KEY_TEXT_SEPARATOR}{Convert.ToBase64String(macKey)}"; + }); } /// - /// Combines the provided encoded recovery key into a instance. + /// Combines the provided encoded recovery key into a instance. /// /// The Base64 encoded recovery key. - /// A instance representing the combined recovery key. - public static SecretKey CombineRecoveryKey(string encodedRecoveryKey) + /// A instance representing the combined recovery key. + public static ManagedKey CombineRecoveryKey(string encodedRecoveryKey) { var keySplit = encodedRecoveryKey.ReplaceLineEndings(string.Empty).Split(Constants.KeyTraits.KEY_TEXT_SEPARATOR); - using var recoveryKey = new SecureKey(Constants.KeyTraits.DEK_KEY_LENGTH + Constants.KeyTraits.MAC_KEY_LENGTH); + using var recoveryKey = new ManagedKey(Constants.KeyTraits.DEK_KEY_LENGTH + Constants.KeyTraits.MAC_KEY_LENGTH); if (!Convert.TryFromBase64String(keySplit[0], recoveryKey.Key.AsSpan(0, Constants.KeyTraits.DEK_KEY_LENGTH), out _)) throw new FormatException("The recovery key (1) was not in the correct format."); @@ -70,15 +167,18 @@ public static SecretKey CombineRecoveryKey(string encodedRecoveryKey) /// /// The combined recovery key. /// A instance representing the key pair. - public static KeyPair CopyFromRecoveryKey(SecretKey recoveryKey) + public static KeyPair CopyFromRecoveryKey(IKeyUsage recoveryKey) { - var dekKey = new SecureKey(Constants.KeyTraits.DEK_KEY_LENGTH); - var macKey = new SecureKey(Constants.KeyTraits.MAC_KEY_LENGTH); + var dekKey = new byte[Constants.KeyTraits.DEK_KEY_LENGTH]; + var macKey = new byte[Constants.KeyTraits.MAC_KEY_LENGTH]; - recoveryKey.Key.AsSpan(0, Constants.KeyTraits.DEK_KEY_LENGTH).CopyTo(dekKey.Key); - recoveryKey.Key.AsSpan(Constants.KeyTraits.DEK_KEY_LENGTH, Constants.KeyTraits.MAC_KEY_LENGTH).CopyTo(macKey.Key); + recoveryKey.UseKey(key => + { + key.Slice(0, Constants.KeyTraits.DEK_KEY_LENGTH).CopyTo(dekKey); + key.Slice(Constants.KeyTraits.DEK_KEY_LENGTH, Constants.KeyTraits.MAC_KEY_LENGTH).CopyTo(macKey); + }); - return new KeyPair(dekKey, macKey); + return new KeyPair(SecureKey.TakeOwnership(dekKey), SecureKey.TakeOwnership(macKey)); } /// diff --git a/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/ManagedKey.cs b/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/ManagedKey.cs new file mode 100644 index 000000000..7911e9e2c --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/ManagedKey.cs @@ -0,0 +1,83 @@ +using System; +using System.Buffers; +using System.Security.Cryptography; +using SecureFolderFS.Shared.ComponentModel; + +namespace SecureFolderFS.Core.Cryptography.SecureStore +{ + /// + public sealed class ManagedKey : IKeyBytes, ICloneable + { + /// + public byte[] Key { get; } + + /// + public int Length { get; } + + public ManagedKey(int size) + { + Key = new byte[size]; + Length = size; + } + + private ManagedKey(byte[] key) + { + Key = key; + Length = key.Length; + } + + /// + public void UseKey(Action> keyAction) + { + keyAction(Key); + } + + /// + public void UseKey(TState state, ReadOnlySpanAction keyAction) + { + keyAction(Key, state); + } + + /// + public TResult UseKey(Func, TResult> keyAction) + { + return keyAction(Key); + } + + /// + public TResult UseKey(TState state, ReadOnlySpanFunc keyAction) + { + return keyAction(Key, state); + } + + /// + public object Clone() + { + var secureKey = new ManagedKey(Key.Length); + Array.Copy(Key, 0, secureKey.Key, 0, Key.Length); + + return secureKey; + } + + /// + public void Dispose() + { + CryptographicOperations.ZeroMemory(Key); + } + + /// + /// Takes the ownership of the provided key and manages its lifetime. + /// + /// The key to import. + public static ManagedKey TakeOwnership(byte[] key) + { + return new ManagedKey(key); + } + + /// + /// Converts into of type . + /// + /// The instance to convert. + public static implicit operator ReadOnlySpan(ManagedKey managedKey) => managedKey.Key; + } +} \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/SecretKey.cs b/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/SecretKey.cs deleted file mode 100644 index 8005c5c3d..000000000 --- a/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/SecretKey.cs +++ /dev/null @@ -1,50 +0,0 @@ -using SecureFolderFS.Shared.ComponentModel; -using System; -using System.Collections; -using System.Collections.Generic; - -namespace SecureFolderFS.Core.Cryptography.SecureStore -{ - /// - /// Represents a secret key store. - /// - public abstract class SecretKey : IKey - { - /// - /// Gets the underlying byte representation of the key. - /// - public abstract byte[] Key { get; } - - /// - /// Gets the number of bytes in the . - /// - public virtual int Length => Key.Length; - - /// - public virtual IEnumerator GetEnumerator() - { - return ((IEnumerable)Key).GetEnumerator(); - } - - /// - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - /// - /// Creates a standalone copy of the key. - /// - /// A new copy of . - public abstract SecretKey CreateCopy(); - - /// - /// Converts into of type . - /// - /// The instance to convert. - public static implicit operator ReadOnlySpan(SecretKey secretKey) => secretKey.Key; - - /// - public abstract void Dispose(); - } -} diff --git a/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/SecureKey.cs b/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/SecureKey.cs index 0b007e0e7..3a5d08b45 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/SecureKey.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/SecureStore/SecureKey.cs @@ -1,45 +1,454 @@ using System; +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using SecureFolderFS.Core.Cryptography.UnsafeNative; +using SecureFolderFS.Shared.ComponentModel; +using static SecureFolderFS.Shared.SharedConfiguration; namespace SecureFolderFS.Core.Cryptography.SecureStore { - /// - public sealed class SecureKey : SecretKey + /// + /// A secure key implementation that protects key material in memory. + /// + /// + /// When is enabled, this class provides additional security measures: + /// + /// Memory is pinned to prevent GC from moving it (which would leave copies in memory) + /// Key material is XOR'd with a random mask, so the plaintext key never exists on the heap + /// When accessing the key via UseKey, it's de-XOR'd into a stack-allocated buffer + /// On Windows/Unix, memory pages are locked to prevent swapping to disk + /// On disposal, memory is securely zeroed using constant-time operations + /// + /// Without memory hardening, the key is stored in plaintext for maximum performance. + /// + public sealed class SecureKey : IKeyUsage, ICloneable { + // Maximum key size for stack allocation (256 bytes = 2048 bits, covers most crypto keys) + private const int MAX_STACK_ALLOC_SIZE = 256; + + private readonly byte[] _obfuscatedKey; + private readonly byte[]? _xorMask; + private readonly bool _isMemoryLocked; + private bool _disposed; + + /// + public int Length { get; } + + /// + /// Gets a value indicating whether this key has been disposed of. + /// + public bool IsDisposed => _disposed; + + private SecureKey(byte[] key, bool takeOwnership) + { + Length = key.Length; + + if (UseCoreMemoryProtection) + { + // Always create a new secure copy with XOR obfuscation + _obfuscatedKey = GC.AllocateArray(key.Length, pinned: true); + _xorMask = GC.AllocateArray(key.Length, pinned: true); + RandomNumberGenerator.Fill(_xorMask); + + _isMemoryLocked = TryLockMemory(_obfuscatedKey) && TryLockMemory(_xorMask); + + // XOR the key with mask and store + XorBytes(key.AsSpan(), _xorMask.AsSpan(), _obfuscatedKey.AsSpan()); + + // If we took ownership, zero the original + if (takeOwnership) + CryptographicOperations.ZeroMemory(key); + } + else + { + if (takeOwnership) + { + _obfuscatedKey = key; + } + else + { + _obfuscatedKey = new byte[key.Length]; + key.AsSpan().CopyTo(_obfuscatedKey); + } + } + } + /// - public override byte[] Key { get; } + public void UseKey(Action> keyAction) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(keyAction); + + if (!UseCoreMemoryProtection || _xorMask is null) + { + // Fast path: no obfuscation, just use the key directly + keyAction(_obfuscatedKey.AsSpan()); + return; + } + + // Slow path with XOR obfuscation: de-XOR into stack buffer + if (Length <= MAX_STACK_ALLOC_SIZE) + { + // Stack allocate for small keys + Span tempKey = stackalloc byte[Length]; + try + { + XorBytes(_obfuscatedKey.AsSpan(), _xorMask.AsSpan(), tempKey); + keyAction(tempKey); + } + finally + { + CryptographicOperations.ZeroMemory(tempKey); + } + } + else + { + // For larger keys, use a pinned array (rare case) + var tempKey = GC.AllocateArray(Length, pinned: true); + try + { + XorBytes(_obfuscatedKey.AsSpan(), _xorMask.AsSpan(), tempKey.AsSpan()); + keyAction(tempKey.AsSpan()); + } + finally + { + CryptographicOperations.ZeroMemory(tempKey); + } + } + } + + /// + public void UseKey(TState state, ReadOnlySpanAction keyAction) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(keyAction); + + if (!UseCoreMemoryProtection || _xorMask is null) + { + // Fast path: no obfuscation, just use the key directly + keyAction(_obfuscatedKey.AsSpan(), state); + return; + } - public SecureKey(int size) + // Slow path with XOR obfuscation: de-XOR into stack buffer + if (Length <= MAX_STACK_ALLOC_SIZE) + { + // Stack allocate for small keys + Span tempKey = stackalloc byte[Length]; + try + { + XorBytes(_obfuscatedKey.AsSpan(), _xorMask.AsSpan(), tempKey); + keyAction(tempKey, state); + } + finally + { + CryptographicOperations.ZeroMemory(tempKey); + } + } + else + { + // For larger keys, use a pinned array (rare case) + var tempKey = GC.AllocateArray(Length, pinned: true); + try + { + XorBytes(_obfuscatedKey.AsSpan(), _xorMask.AsSpan(), tempKey.AsSpan()); + keyAction(tempKey.AsSpan(), state); + } + finally + { + CryptographicOperations.ZeroMemory(tempKey); + } + } + } + + /// + public TResult UseKey(Func, TResult> keyAction) { - Key = new byte[size]; + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(keyAction); + + if (!UseCoreMemoryProtection || _xorMask is null) + { + // Fast path: no obfuscation, just use the key directly + return keyAction(_obfuscatedKey.AsSpan()); + } + + // Slow path with XOR obfuscation: de-XOR into stack buffer + if (Length <= MAX_STACK_ALLOC_SIZE) + { + // Stack allocate for small keys + Span tempKey = stackalloc byte[Length]; + try + { + XorBytes(_obfuscatedKey.AsSpan(), _xorMask.AsSpan(), tempKey); + return keyAction(tempKey); + } + finally + { + CryptographicOperations.ZeroMemory(tempKey); + } + } + else + { + // For larger keys, use a pinned array (rare case) + var tempKey = GC.AllocateArray(Length, pinned: true); + try + { + XorBytes(_obfuscatedKey.AsSpan(), _xorMask.AsSpan(), tempKey.AsSpan()); + return keyAction(tempKey.AsSpan()); + } + finally + { + CryptographicOperations.ZeroMemory(tempKey); + } + } } - private SecureKey(byte[] key) + /// + public TResult UseKey(TState state, ReadOnlySpanFunc keyAction) { - Key = key; + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(keyAction); + + if (!UseCoreMemoryProtection || _xorMask is null) + { + // Fast path: no obfuscation, just use the key directly + return keyAction(_obfuscatedKey.AsSpan(), state); + } + + // Slow path with XOR obfuscation: de-XOR into stack buffer + if (Length <= MAX_STACK_ALLOC_SIZE) + { + // Stack allocate for small keys + Span tempKey = stackalloc byte[Length]; + try + { + XorBytes(_obfuscatedKey.AsSpan(), _xorMask.AsSpan(), tempKey); + return keyAction(tempKey, state); + } + finally + { + CryptographicOperations.ZeroMemory(tempKey); + } + } + else + { + // For larger keys, use a pinned array (rare case) + var tempKey = GC.AllocateArray(Length, pinned: true); + try + { + XorBytes(_obfuscatedKey.AsSpan(), _xorMask.AsSpan(), tempKey.AsSpan()); + return keyAction(tempKey, state); + } + finally + { + CryptographicOperations.ZeroMemory(tempKey); + } + } } /// - public override SecretKey CreateCopy() + public object Clone() { - var secureKey = new SecureKey(Key.Length); - Array.Copy(Key, 0, secureKey.Key, 0, Key.Length); + ObjectDisposedException.ThrowIf(_disposed, this); - return secureKey; + // De-XOR and create a new copy + return UseKey(key => + { + var copy = new byte[Length]; + key.CopyTo(copy); + return new SecureKey(copy, takeOwnership: true); + }); } /// - public override void Dispose() + public void Dispose() { - Array.Clear(Key); + if (_disposed) + return; + + _disposed = true; + + // Securely zero the memory using constant-time operation + CryptographicOperations.ZeroMemory(_obfuscatedKey); + if (_xorMask is not null) + CryptographicOperations.ZeroMemory(_xorMask); + + if (UseCoreMemoryProtection) + { + // Unlock memory pages if they were locked + if (_isMemoryLocked) + { + TryUnlockMemory(_obfuscatedKey); + if (_xorMask is not null) + TryUnlockMemory(_xorMask); + } + + // Note: Arrays allocated with GC.AllocateArray(pinned: true) are on the POH + // They don't need explicit unpinning - they're freed when GC collects them + } } /// /// Takes the ownership of the provided key and manages its lifetime. /// /// The key to import. - public static SecretKey TakeOwnership(byte[] key) + /// + /// When memory hardening is enabled, the key will be XOR-obfuscated immediately, + /// and the original array will be securely zeroed. + /// + public static SecureKey TakeOwnership(byte[] key) + { + return new SecureKey(key, takeOwnership: true); + } + + /// + /// Creates a new by copying data from the provided key. + /// + /// The key to copy from. + public static SecureKey FromCopy(byte[] key) { - return new SecureKey(key); + return new SecureKey(key, takeOwnership: false); } + + /// + /// Creates a new filled with cryptographically secure random bytes. + /// + /// The size of the key in bytes. + /// A new containing random key material. + public static SecureKey CreateSecureRandom(int size) + { + var randomBytes = new byte[size]; + RandomNumberGenerator.Fill(randomBytes); + + return new SecureKey(randomBytes, takeOwnership: true); + } + + /// + /// Creates a new and copies the data from a span of bytes. + /// + /// The key data to copy. + /// A new containing the provided key material. + public static SecureKey FromSpanCopy(ReadOnlySpan key) + { + var copy = new byte[key.Length]; + key.CopyTo(copy); + return new SecureKey(copy, takeOwnership: true); + } + + /// + /// XORs source with mask and writes to destination. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void XorBytes(ReadOnlySpan source, ReadOnlySpan mask, Span destination) + { + for (var i = 0; i < source.Length; i++) + destination[i] = (byte)(source[i] ^ mask[i]); + } + + #region Platform-specific memory locking + + /// + /// Attempts to lock memory pages to prevent them from being swapped to disk. + /// This protects against cold boot attacks and forensic recovery from swap files. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryLockMemory(byte[] buffer) + { + if (!UseCoreMemoryProtection || buffer.Length == 0) + return false; + + try + { + if (OperatingSystem.IsWindows()) + return TryLockMemoryWindows(buffer); + + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + return TryLockMemoryUnix(buffer); + } + catch + { + // Silently fail - memory locking is a best-effort security enhancement + // The application should still work without it (e.g., insufficient privileges) + } + + return false; + } + + /// + /// Attempts to unlock previously locked memory pages. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void TryUnlockMemory(byte[] buffer) + { + if (!UseCoreMemoryProtection || buffer.Length == 0) + return; + + try + { + if (OperatingSystem.IsWindows()) + TryUnlockMemoryWindows(buffer); + else if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + TryUnlockMemoryUnix(buffer); + } + catch + { + // Silently fail - unlocking failure is not critical + } + } + + #region Windows Memory Locking + + [SupportedOSPlatform("windows")] + private static unsafe bool TryLockMemoryWindows(byte[] buffer) + { + // VirtualLock prevents pages from being swapped to the pagefile + // The buffer is already pinned (allocated on POH), so we can safely get its address + fixed (byte* ptr = buffer) + { + return UnsafeNativeApis.VirtualLock((nint)ptr, (nuint)buffer.Length); + } + } + + [SupportedOSPlatform("windows")] + private static unsafe bool TryUnlockMemoryWindows(byte[] buffer) + { + fixed (byte* ptr = buffer) + { + return UnsafeNativeApis.VirtualUnlock((nint)ptr, (nuint)buffer.Length); + } + } + + #endregion + + #region Unix Memory Locking (Linux/macOS) + + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + private static unsafe bool TryLockMemoryUnix(byte[] buffer) + { + // mlock prevents pages from being swapped out + // The buffer is already pinned (allocated on POH), so we can safely get its address + fixed (byte* ptr = buffer) + { + return UnsafeNativeApis.mlock((nint)ptr, (nuint)buffer.Length) == 0; + } + } + + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + private static unsafe bool TryUnlockMemoryUnix(byte[] buffer) + { + fixed (byte* ptr = buffer) + { + return UnsafeNativeApis.munlock((nint)ptr, (nuint)buffer.Length) == 0; + } + } + + #endregion + + #endregion } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/UnsafeNative/UnsafeNativeApis.cs b/src/Core/SecureFolderFS.Core.Cryptography/UnsafeNative/UnsafeNativeApis.cs new file mode 100644 index 000000000..8ccd75872 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/UnsafeNative/UnsafeNativeApis.cs @@ -0,0 +1,68 @@ +using System; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace SecureFolderFS.Core.Cryptography.UnsafeNative +{ + /// + /// Provides access to native platform APIs for memory protection and other security operations. + /// + internal static class UnsafeNativeApis + { + #region Windows Memory Locking + + /// + /// Locks the specified region of the process's virtual address space into physical memory, + /// preventing the system from swapping the region to the paging file. + /// + /// A pointer to the base address of the region of pages to be locked. + /// The size of the region to be locked, in bytes. + /// True if the function succeeds; otherwise, false. + [DllImport("kernel32.dll", SetLastError = true)] + [SupportedOSPlatform("windows")] + public static extern bool VirtualLock(IntPtr lpAddress, nuint dwSize); + + /// + /// Unlocks a specified range of pages in the virtual address space of a process, + /// enabling the system to swap the pages out to the paging file if necessary. + /// + /// A pointer to the base address of the region of pages to be unlocked. + /// The size of the region being unlocked, in bytes. + /// True if the function succeeds; otherwise, false. + [DllImport("kernel32.dll", SetLastError = true)] + [SupportedOSPlatform("windows")] + public static extern bool VirtualUnlock(IntPtr lpAddress, nuint dwSize); + + #endregion + + #region Unix Memory Locking (Linux/macOS) + + /// + /// Locks pages in the address range starting at addr and continuing for len bytes. + /// All pages that contain a part of the specified address range are guaranteed to be + /// resident in RAM when the call returns successfully. + /// + /// The starting address of the memory region to lock. + /// The length of the memory region to lock. + /// 0 on success; -1 on error. + [DllImport("libc", SetLastError = true)] + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + public static extern int mlock(IntPtr addr, nuint len); + + /// + /// Unlocks pages in the address range starting at addr and continuing for len bytes. + /// After this call, all pages that contain a part of the specified memory range can + /// be moved to external swap space again by the kernel. + /// + /// The starting address of the memory region to unlock. + /// The length of the memory region to unlock. + /// 0 on success; -1 on error. + [DllImport("libc", SetLastError = true)] + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + public static extern int munlock(IntPtr addr, nuint len); + + #endregion + } +} diff --git a/src/Core/SecureFolderFS.Core.Dokany/Constants.cs b/src/Core/SecureFolderFS.Core.Dokany/Constants.cs index 3d4c35a86..56004e78a 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/Constants.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/Constants.cs @@ -7,6 +7,7 @@ public static class FileSystem public const string FS_ID = "DOKANY"; public const string FS_NAME = "Dokany"; public const string VERSION_STRING = "2.3.1"; + public const string VERSION_TAG = "v2.3.1.1000"; } internal static class Dokan diff --git a/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.Availability.cs b/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.Availability.cs index 5c1270f07..425f39f90 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.Availability.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.Availability.cs @@ -7,7 +7,7 @@ namespace SecureFolderFS.Core.Dokany { - /// + /// public sealed partial class DokanyFileSystem { /// diff --git a/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs b/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs index 92d775911..c9533aae0 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs @@ -1,4 +1,9 @@ -using OwlCore.Storage; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.Dokany.AppModels; using SecureFolderFS.Core.Dokany.Callbacks; @@ -7,20 +12,16 @@ using SecureFolderFS.Core.FileSystem.AppModels; using SecureFolderFS.Core.FileSystem.Extensions; using SecureFolderFS.Core.FileSystem.Helpers.Paths; +using SecureFolderFS.Core.FileSystem.Storage; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Storage.Enums; using SecureFolderFS.Storage.SystemStorageEx; using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Core.Dokany { - /// - public sealed partial class DokanyFileSystem : IFileSystem + /// + public sealed partial class DokanyFileSystem : IFileSystemInfo { /// public string Id { get; } = Constants.FileSystem.FS_ID; @@ -32,7 +33,7 @@ public sealed partial class DokanyFileSystem : IFileSystem public partial Task GetStatusAsync(CancellationToken cancellationToken = default); /// - public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) + public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) { if (unlockContract is not IWrapper wrapper) throw new ArgumentException($"The {nameof(unlockContract)} is invalid."); @@ -56,7 +57,9 @@ public async Task MountAsync(IFolder folder, IDisposable unlockContrac // Await a short delay before locating the folder await Task.Delay(500); - return new DokanyVFSRoot(dokanyWrapper, new SystemFolderEx(dokanyOptions.MountPoint), specifics); + var virtualizedRoot = new SystemFolderEx(dokanyOptions.MountPoint); + var plaintextRoot = new CryptoFolder(Path.DirectorySeparatorChar.ToString(), specifics.ContentFolder, specifics); + return new DokanyVfsRoot(dokanyWrapper, virtualizedRoot, plaintextRoot, specifics); } } } diff --git a/src/Core/SecureFolderFS.Core.Dokany/DokanyVFSRoot.cs b/src/Core/SecureFolderFS.Core.Dokany/DokanyVfsRoot.cs similarity index 74% rename from src/Core/SecureFolderFS.Core.Dokany/DokanyVFSRoot.cs rename to src/Core/SecureFolderFS.Core.Dokany/DokanyVfsRoot.cs index a429e1636..d45e9c03e 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/DokanyVFSRoot.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/DokanyVfsRoot.cs @@ -5,8 +5,8 @@ namespace SecureFolderFS.Core.Dokany { - /// - internal sealed class DokanyVFSRoot : VFSRoot + /// + internal sealed class DokanyVfsRoot : VfsRoot { private readonly DokanyWrapper _dokanyWrapper; private bool _disposed; @@ -14,8 +14,8 @@ internal sealed class DokanyVFSRoot : VFSRoot /// public override string FileSystemName { get; } = Constants.FileSystem.FS_NAME; - public DokanyVFSRoot(DokanyWrapper dokanyWrapper, IFolder storageRoot, FileSystemSpecifics specifics) - : base(storageRoot, specifics) + public DokanyVfsRoot(DokanyWrapper dokanyWrapper, IFolder virtualizedRoot, IFolder plaintextRoot, FileSystemSpecifics specifics) + : base(virtualizedRoot, plaintextRoot, specifics) { _dokanyWrapper = dokanyWrapper; } diff --git a/src/Core/SecureFolderFS.Core.FUSE/FuseFileSystem.cs b/src/Core/SecureFolderFS.Core.FUSE/FuseFileSystem.cs index 147b47911..ce2ca00ba 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/FuseFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/FuseFileSystem.cs @@ -2,6 +2,7 @@ using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem; using SecureFolderFS.Core.FileSystem.Extensions; +using SecureFolderFS.Core.FileSystem.Storage; using SecureFolderFS.Core.FUSE.AppModels; using SecureFolderFS.Core.FUSE.Callbacks; using SecureFolderFS.Core.FUSE.OpenHandles; @@ -13,7 +14,7 @@ namespace SecureFolderFS.Core.FUSE { /// - public sealed partial class FuseFileSystem : IFileSystem + public sealed partial class FuseFileSystem : IFileSystemInfo { /// public string Id { get; } = Constants.FileSystem.FS_ID; @@ -25,8 +26,9 @@ public sealed partial class FuseFileSystem : IFileSystem public partial Task GetStatusAsync(CancellationToken cancellationToken = default); /// - public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) + public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) { + await Task.CompletedTask; if (unlockContract is not IWrapper wrapper) throw new ArgumentException($"The {nameof(unlockContract)} is invalid."); @@ -61,8 +63,9 @@ public async Task MountAsync(IFolder folder, IDisposable unlockContrac var fuseWrapper = new FuseWrapper(fuseCallbacks); fuseWrapper.StartFileSystem(mountPoint, fuseOptions); - await Task.CompletedTask; - return new FuseVFSRoot(fuseWrapper, new SystemFolderEx(mountPoint), specifics); + var virtualizedRoot = new SystemFolderEx(mountPoint); + var plaintextRoot = new CryptoFolder(Path.DirectorySeparatorChar.ToString(), specifics.ContentFolder, specifics); + return new FuseVfsRoot(fuseWrapper, virtualizedRoot, plaintextRoot, specifics); } } } diff --git a/src/Core/SecureFolderFS.Core.FUSE/FuseVFSRoot.cs b/src/Core/SecureFolderFS.Core.FUSE/FuseVfsRoot.cs similarity index 67% rename from src/Core/SecureFolderFS.Core.FUSE/FuseVFSRoot.cs rename to src/Core/SecureFolderFS.Core.FUSE/FuseVfsRoot.cs index fa0cbbd2b..685c5cb44 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/FuseVFSRoot.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/FuseVfsRoot.cs @@ -4,8 +4,8 @@ namespace SecureFolderFS.Core.FUSE { - /// - internal sealed class FuseVFSRoot : VFSRoot + /// + internal sealed class FuseVfsRoot : VfsRoot { private readonly FuseWrapper _fuseWrapper; private bool _disposed; @@ -13,8 +13,8 @@ internal sealed class FuseVFSRoot : VFSRoot /// public override string FileSystemName { get; } = Constants.FileSystem.FS_NAME; - public FuseVFSRoot(FuseWrapper fuseWrapper, IFolder storageRoot, FileSystemSpecifics specifics) - : base(storageRoot, specifics) + public FuseVfsRoot(FuseWrapper fuseWrapper, IFolder virtualizedRoot, IFolder plaintextRoot, FileSystemSpecifics specifics) + : base(virtualizedRoot, plaintextRoot, specifics) { _fuseWrapper = fuseWrapper; } @@ -27,7 +27,10 @@ public override async ValueTask DisposeAsync() _disposed = await _fuseWrapper.CloseFileSystemAsync(); if (_disposed) + { FileSystemManager.Instance.FileSystems.Remove(this); + await base.DisposeAsync(); + } } } } \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.FileSystem/AppModels/FileSystemStatistics.cs b/src/Core/SecureFolderFS.Core.FileSystem/AppModels/FileSystemStatistics.cs index 1f763cd83..c653d4b86 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/AppModels/FileSystemStatistics.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/AppModels/FileSystemStatistics.cs @@ -1,12 +1,40 @@ -using SecureFolderFS.Shared.Enums; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Enums; using SecureFolderFS.Storage.VirtualFileSystem; using System; namespace SecureFolderFS.Core.FileSystem.AppModels { /// - public sealed class FileSystemStatistics : IFileSystemStatistics + public sealed class FileSystemStatistics : IFileSystemStatisticsSubscriber, IDisposable { + private readonly MulticastProgress _bytesReadMulticast; + private readonly MulticastProgress _bytesWrittenMulticast; + private readonly MulticastProgress _bytesEncryptedMulticast; + private readonly MulticastProgress _bytesDecryptedMulticast; + private readonly MulticastProgress _chunkCacheMulticast; + private readonly MulticastProgress _fileNameCacheMulticast; + private readonly MulticastProgress _directoryIdCacheMulticast; + + public FileSystemStatistics() + { + _bytesReadMulticast = new MulticastProgress(); + _bytesWrittenMulticast = new MulticastProgress(); + _bytesEncryptedMulticast = new MulticastProgress(); + _bytesDecryptedMulticast = new MulticastProgress(); + _chunkCacheMulticast = new MulticastProgress(); + _fileNameCacheMulticast = new MulticastProgress(); + _directoryIdCacheMulticast = new MulticastProgress(); + + BytesRead = _bytesReadMulticast; + BytesWritten = _bytesWrittenMulticast; + BytesEncrypted = _bytesEncryptedMulticast; + BytesDecrypted = _bytesDecryptedMulticast; + ChunkCache = _chunkCacheMulticast; + FileNameCache = _fileNameCacheMulticast; + DirectoryIdCache = _directoryIdCacheMulticast; + } + /// public IProgress? BytesRead { get; set; } @@ -27,5 +55,66 @@ public sealed class FileSystemStatistics : IFileSystemStatistics /// public IProgress? DirectoryIdCache { get; set; } + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + public IDisposable SubscribeToBytesRead(IProgress progress) => _bytesReadMulticast.Subscribe(progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + public IDisposable SubscribeToBytesWritten(IProgress progress) => _bytesWrittenMulticast.Subscribe(progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + public IDisposable SubscribeToBytesEncrypted(IProgress progress) => _bytesEncryptedMulticast.Subscribe(progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + public IDisposable SubscribeToBytesDecrypted(IProgress progress) => _bytesDecryptedMulticast.Subscribe(progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + public IDisposable SubscribeToChunkCache(IProgress progress) => _chunkCacheMulticast.Subscribe(progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + public IDisposable SubscribeToFileNameCache(IProgress progress) => _fileNameCacheMulticast.Subscribe(progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + public IDisposable SubscribeToDirectoryIdCache(IProgress progress) => _directoryIdCacheMulticast.Subscribe(progress); + + /// + public void Dispose() + { + _bytesReadMulticast.Dispose(); + _bytesWrittenMulticast.Dispose(); + _bytesEncryptedMulticast.Dispose(); + _bytesDecryptedMulticast.Dispose(); + _chunkCacheMulticast.Dispose(); + _fileNameCacheMulticast.Dispose(); + _directoryIdCacheMulticast.Dispose(); + } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/AppModels/HealthStatistics.cs b/src/Core/SecureFolderFS.Core.FileSystem/AppModels/HealthStatistics.cs index d6544006c..269bf5744 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/AppModels/HealthStatistics.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/AppModels/HealthStatistics.cs @@ -9,7 +9,7 @@ namespace SecureFolderFS.Core.FileSystem.AppModels public sealed class HealthStatistics : IHealthStatistics { /// - public IAsyncValidator? FileValidator { get; set; } + public IAsyncValidator? FileContentValidator { get; set; } /// public IAsyncValidator? FolderValidator { get; set; } @@ -17,6 +17,9 @@ public sealed class HealthStatistics : IHealthStatistics /// public IAsyncValidator<(IFolder, IProgress?), IResult>? StructureValidator { get; set; } + /// + public IAsyncValidator<(IFolder, IProgress?), IResult>? StructureContentsValidator { get; set; } + /// public IProgress? DirectoryIdNotFound { get; set; } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Buffers/ChunkBuffer.cs b/src/Core/SecureFolderFS.Core.FileSystem/Buffers/ChunkBuffer.cs index abbaf2825..abcf166ca 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Buffers/ChunkBuffer.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Buffers/ChunkBuffer.cs @@ -1,9 +1,10 @@ -using SecureFolderFS.Shared.Models; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; namespace SecureFolderFS.Core.FileSystem.Buffers { /// - internal sealed class ChunkBuffer : BufferHolder + internal sealed class ChunkBuffer : BufferHolder, IChangeTracker { /// /// Gets or sets the value that determines whether the chunk has been modified or not. diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs index 0fb55d8d9..f82c17561 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs @@ -1,8 +1,8 @@ -using SecureFolderFS.Core.Cryptography.ContentCrypt; -using SecureFolderFS.Storage.VirtualFileSystem; -using System; +using System; using System.Buffers; using System.Security.Cryptography; +using SecureFolderFS.Core.Cryptography.ContentCrypt; +using SecureFolderFS.Storage.VirtualFileSystem; namespace SecureFolderFS.Core.FileSystem.Chunks { @@ -42,7 +42,7 @@ public virtual int CopyFromChunk(long chunkNumber, Span destination, int o var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize); try { - // ArrayPool may return larger array than requested + // ArrayPool may return a larger array than requested var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize); // Read chunk @@ -63,6 +63,9 @@ public virtual int CopyFromChunk(long chunkNumber, Span destination, int o } finally { + // Clear sensitive plaintext data before returning the buffer to the pool + CryptographicOperations.ZeroMemory(plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize)); + // Return buffer ArrayPool.Shared.Return(plaintextChunk); } @@ -106,6 +109,9 @@ public virtual int CopyToChunk(long chunkNumber, ReadOnlySpan source, int } finally { + // Clear sensitive plaintext data before returning buffer to pool + CryptographicOperations.ZeroMemory(plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize)); + // Return buffer ArrayPool.Shared.Return(plaintextChunk); } @@ -161,6 +167,9 @@ public virtual void SetChunkLength(long chunkNumber, int length, bool includeCur } finally { + // Clear sensitive plaintext data before returning buffer to pool + CryptographicOperations.ZeroMemory(plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize)); + // Return buffer ArrayPool.Shared.Return(plaintextChunk); } @@ -176,8 +185,6 @@ public virtual void Flush() /// public virtual void Dispose() { - chunkReader.Dispose(); - chunkWriter.Dispose(); } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs index c9ecbaa9d..19828376d 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs @@ -1,30 +1,30 @@ -using SecureFolderFS.Core.Cryptography; -using SecureFolderFS.Core.FileSystem.Exceptions; -using SecureFolderFS.Core.FileSystem.Streams; +using System; +using System.Buffers; +using System.Diagnostics; +using System.IO; +using System.Security.Cryptography; +using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.Buffers; -using System.Diagnostics; namespace SecureFolderFS.Core.FileSystem.Chunks { /// /// Provides read access to chunks. /// - internal sealed class ChunkReader : IDisposable + internal sealed class ChunkReader { private readonly Security _security; private readonly BufferHolder _fileHeader; - private readonly StreamsManager _streamsManager; + private readonly Stream _ciphertextStream; private readonly IFileSystemStatistics _fileSystemStatistics; - public ChunkReader(Security security, BufferHolder fileHeader, StreamsManager streamsManager, IFileSystemStatistics fileSystemStatistics) + public ChunkReader(Security security, BufferHolder fileHeader, Stream ciphertextStream, IFileSystemStatistics fileSystemStatistics) { _security = security; _fileHeader = fileHeader; - _streamsManager = streamsManager; + _ciphertextStream = ciphertextStream; _fileSystemStatistics = fileSystemStatistics; } @@ -48,27 +48,23 @@ public int ReadChunk(long chunkNumber, Span plaintextChunk) // ArrayPool may return a larger array than requested var realCiphertextChunk = ciphertextChunk.AsSpan(0, ciphertextSize); - // Get available read stream or throw - var ciphertextStream = _streamsManager.GetReadOnlyStream(); - _ = ciphertextStream ?? throw new UnavailableStreamException(); - // Check position bounds - if (ciphertextPosition > ciphertextStream.Length) + if (_ciphertextStream.CanSeek && _ciphertextStream.Length < ciphertextPosition) return 0; // Set the correct stream position - if (!ciphertextStream.TrySetPositionOrAdvance(ciphertextPosition)) + if (!_ciphertextStream.TrySetPositionOrAdvance(ciphertextPosition)) return 0; // Return early if the stream is at the EOF position - if (ciphertextStream.IsEndOfStream()) + if (_ciphertextStream.IsEndOfStream()) return 0; - // Read from stream at correct chunk - var read = ciphertextStream.Read(realCiphertextChunk); + // Read from the stream at the correct chunk + var read = _ciphertextStream.Read(realCiphertextChunk); // Check for the end of the file - if (read == FileSystem.Constants.FILE_EOF) + if (read == Constants.FILE_EOF) return 0; _fileSystemStatistics.BytesRead?.Report(read); @@ -104,15 +100,12 @@ public int ReadChunk(long chunkNumber, Span plaintextChunk) } finally { + // Clear ciphertext data before returning buffer to pool + CryptographicOperations.ZeroMemory(ciphertextChunk.AsSpan(0, ciphertextSize)); + // Return buffer ArrayPool.Shared.Return(ciphertextChunk); } } - - /// - public void Dispose() - { - _streamsManager.Dispose(); - } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs index 7d2a2fdc7..d272446e1 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs @@ -1,29 +1,29 @@ -using SecureFolderFS.Core.Cryptography; +using System; +using System.Buffers; +using System.IO; +using System.Security.Cryptography; +using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Buffers; -using SecureFolderFS.Core.FileSystem.Exceptions; -using SecureFolderFS.Core.FileSystem.Streams; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.Buffers; namespace SecureFolderFS.Core.FileSystem.Chunks { /// /// Provides write access to chunks. /// - internal sealed class ChunkWriter : IDisposable + internal sealed class ChunkWriter { private readonly Security _security; private readonly HeaderBuffer _fileHeader; - private readonly StreamsManager _streamsManager; + private readonly Stream _ciphertextStream; private readonly IFileSystemStatistics _fileSystemStatistics; - public ChunkWriter(Security security, HeaderBuffer fileHeader, StreamsManager streamsManager, IFileSystemStatistics fileSystemStatistics) + public ChunkWriter(Security security, HeaderBuffer fileHeader, Stream ciphertextStream, IFileSystemStatistics fileSystemStatistics) { _security = security; _fileHeader = fileHeader; - _streamsManager = streamsManager; + _ciphertextStream = ciphertextStream; _fileSystemStatistics = fileSystemStatistics; } @@ -56,34 +56,27 @@ public void WriteChunk(long chunkNumber, ReadOnlySpan plaintextChunk) _fileSystemStatistics.BytesEncrypted?.Report(plaintextChunk.Length); - // Get available read-write stream or throw - var ciphertextStream = _streamsManager.GetReadWriteStream(); - _ = ciphertextStream ?? throw new UnavailableStreamException(); - // Check position bounds - if (streamPosition > ciphertextStream.Length) + if (streamPosition > _ciphertextStream.Length) return; // Set the correct stream position - if (!ciphertextStream.TrySetPositionOrAdvance(streamPosition)) + if (!_ciphertextStream.TrySetPositionOrAdvance(streamPosition)) return; // Write to stream at the correct chunk - ciphertextStream.Write(realCiphertextChunk); + _ciphertextStream.Write(realCiphertextChunk); _fileSystemStatistics.BytesWritten?.Report(realCiphertextChunk.Length); } finally { + // Clear ciphertext data before returning buffer to pool + CryptographicOperations.ZeroMemory(ciphertextChunk.AsSpan(0, ciphertextSize)); + // Return buffer ArrayPool.Shared.Return(ciphertextChunk); } } - - /// - public void Dispose() - { - _streamsManager.Dispose(); - } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs b/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs index 94ff9f94b..6f90d82f0 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs @@ -7,7 +7,7 @@ public static class Constants public const int FILE_EOF = 0; public const int DIRECTORY_ID_SIZE = 16; public const ulong INVALID_HANDLE = 0UL; - public const bool OPT_IN_FOR_OPTIONAL_DEBUG_TRACING = true; + public const bool OPT_IN_FOR_OPTIONAL_DEBUG_TRACING = false; public static class FileSystem { diff --git a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs index dd91845af..1fb2ad119 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs @@ -1,11 +1,11 @@ -using SecureFolderFS.Core.Cryptography; +using System; +using System.Collections.Generic; +using System.IO; +using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Buffers; using SecureFolderFS.Core.FileSystem.Chunks; using SecureFolderFS.Core.FileSystem.Streams; using SecureFolderFS.Shared.Extensions; -using System; -using System.Collections.Generic; -using System.IO; namespace SecureFolderFS.Core.FileSystem.CryptFiles { @@ -15,10 +15,9 @@ namespace SecureFolderFS.Core.FileSystem.CryptFiles internal sealed class OpenCryptFile : IDisposable { private readonly Security _security; - private readonly ChunkAccess _chunkAccess; private readonly Action _notifyClosed; - private readonly StreamsManager _streamsManager; - private readonly Dictionary _openedStreams; + private readonly OpenCryptFileManager _cryptFileManager; + private readonly Dictionary _openedStreams; /// /// Gets the unique ID of the file. @@ -34,15 +33,13 @@ public OpenCryptFile( string id, Security security, HeaderBuffer headerBuffer, - ChunkAccess chunkAccess, - StreamsManager streamsManager, + OpenCryptFileManager cryptFileManager, Action notifyClosed) { Id = id; HeaderBuffer = headerBuffer; _security = security; - _chunkAccess = chunkAccess; - _streamsManager = streamsManager; + _cryptFileManager = cryptFileManager; _notifyClosed = notifyClosed; _openedStreams = new(); } @@ -54,28 +51,32 @@ public OpenCryptFile( /// A new instance of . public PlaintextStream OpenStream(Stream ciphertextStream) { - // Register the ciphertext stream - if (_openedStreams.TryGetValue(ciphertextStream, out var value)) - _openedStreams[ciphertextStream] = ++value; + if (_openedStreams.TryGetValue(ciphertextStream, out var existing)) + _openedStreams[ciphertextStream] = (existing.RefCount + 1, existing.ChunkAccess); else - _openedStreams.Add(ciphertextStream, 1L); - - // Make sure to also add it to streams manager - _streamsManager.AddStream(ciphertextStream); + { + var chunkAccess = _cryptFileManager.CreateChunkAccess(ciphertextStream, HeaderBuffer); + _openedStreams.Add(ciphertextStream, (1, chunkAccess)); + } - // Open the plaintext stream - return new PlaintextStream(ciphertextStream, _security, _chunkAccess, HeaderBuffer, NotifyClosed); + return new PlaintextStream(ciphertextStream, _security, _openedStreams[ciphertextStream].ChunkAccess, HeaderBuffer, NotifyClosed); } private void NotifyClosed(Stream ciphertextStream) { - // Make sure to remove it and update the reference count - if (_openedStreams.ContainsKey(ciphertextStream) && --_openedStreams[ciphertextStream] <= 0) - _openedStreams.Remove(ciphertextStream); - - // Dispose of the stream - _streamsManager.RemoveStream(ciphertextStream); - ciphertextStream.Dispose(); + if (_openedStreams.TryGetValue(ciphertextStream, out var existing)) + { + // Make sure to remove it and update the reference count + if (--existing.RefCount <= 0) + { + // Dispose of the stream + _openedStreams.Remove(ciphertextStream); + existing.ChunkAccess.Dispose(); + ciphertextStream.Dispose(); + } + else + _openedStreams[ciphertextStream] = (existing.RefCount, existing.ChunkAccess); + } // Notify closed if no streams left if (_openedStreams.IsEmpty()) @@ -88,9 +89,12 @@ private void NotifyClosed(Stream ciphertextStream) /// public void Dispose() { - _streamsManager.Dispose(); - _openedStreams.Keys.DisposeAll(); + foreach (var (stream, (_, chunkAccess)) in _openedStreams) + { + chunkAccess.Dispose(); + stream.Dispose(); + } _openedStreams.Clear(); } } -} +} \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs index 3b5cfb7ac..182241a0a 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs @@ -1,12 +1,12 @@ using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Buffers; using SecureFolderFS.Core.FileSystem.Chunks; -using SecureFolderFS.Core.FileSystem.Streams; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.VirtualFileSystem; using System; using System.Collections.Generic; +using System.IO; namespace SecureFolderFS.Core.FileSystem.CryptFiles { @@ -46,29 +46,24 @@ public OpenCryptFileManager(Security security, bool enableChunkCache, IFileSyste /// The plaintext header of the file. /// If successful, returns an instance of . public OpenCryptFile NewCryptFile(string id, BufferHolder headerBuffer) - { - var cryptFile = GetCryptFile(id, headerBuffer); - lock (_openCryptFiles) - _openCryptFiles[id] = cryptFile; - - return cryptFile; - } - - private OpenCryptFile GetCryptFile(string id, BufferHolder headerBuffer) { if (headerBuffer is not HeaderBuffer headerBuffer2) throw new ArgumentException($"{nameof(headerBuffer)} does not implement {nameof(HeaderBuffer)}."); - var streamsManager = new StreamsManager(); - var chunkAccess = GetChunkAccess(streamsManager, headerBuffer2); + var cryptFile = new OpenCryptFile(id, _security, headerBuffer2, this, NotifyClosed); + lock (_openCryptFiles) + _openCryptFiles[id] = cryptFile; - return new OpenCryptFile(id, _security, headerBuffer2, chunkAccess, streamsManager, NotifyClosed); + return cryptFile; } - private ChunkAccess GetChunkAccess(StreamsManager streamsManager, HeaderBuffer headerBuffer) + /// + /// Creates a new bound exclusively to . + /// + internal ChunkAccess CreateChunkAccess(Stream ciphertextStream, HeaderBuffer headerBuffer) { - var chunkReader = new ChunkReader(_security, headerBuffer, streamsManager, _fileSystemStatistics); - var chunkWriter = new ChunkWriter(_security, headerBuffer, streamsManager, _fileSystemStatistics); + var chunkReader = new ChunkReader(_security, headerBuffer, ciphertextStream, _fileSystemStatistics); + var chunkWriter = new ChunkWriter(_security, headerBuffer, ciphertextStream, _fileSystemStatistics); return _enableChunkCache ? new CachingChunkAccess(chunkReader, chunkWriter, _security.ContentCrypt, _fileSystemStatistics) @@ -91,4 +86,4 @@ public void Dispose() } } } -} +} \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.FileSystem/DataModels/RecycleBinDataModel.cs b/src/Core/SecureFolderFS.Core.FileSystem/DataModels/RecycleBinDataModel.cs new file mode 100644 index 000000000..beb749aef --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/DataModels/RecycleBinDataModel.cs @@ -0,0 +1,17 @@ +using System; +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace SecureFolderFS.Core.FileSystem.DataModels +{ + [Serializable] + public sealed record class RecycleBinDataModel + { + /// + /// Gets or sets the accumulated total size of the contents currently present in the recycle bin, measured in bytes. + /// + [JsonPropertyName("occupiedSize")] + [DefaultValue(0)] + public long OccupiedSize { get; set; } + } +} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs index 3e7a1dc28..83c92ec58 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs @@ -18,23 +18,25 @@ public static bool ReadHeader(this HeaderBuffer headerBuffer, Stream ciphertextS if (!ciphertextStream.CanRead) throw FileSystemExceptions.StreamNotReadable; - var ciphertextPosition = ciphertextStream.Position; - if (ciphertextPosition != 0 && !ciphertextStream.CanSeek) - throw FileSystemExceptions.StreamNotSeekable; - // Allocate ciphertext header Span ciphertextHeader = stackalloc byte[security.HeaderCrypt.HeaderCiphertextSize]; // Read header int read; - if (ciphertextPosition != 0) + if (ciphertextStream.CanSeek && ciphertextStream.Position != 0L) { + var ciphertextPosition = ciphertextStream.Position; ciphertextStream.Position = 0L; + read = ciphertextStream.Read(ciphertextHeader); ciphertextStream.Position = ciphertextPosition; } else + { + // Non-seekable streams must be at position 0 - header is always read first sequentially. + // There is no way to rewind, so we simply read and continue. read = ciphertextStream.Read(ciphertextHeader); + } // Check if the read amount is correct if (read < ciphertextHeader.Length) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileSystemOptionsExtensions.cs b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileSystemOptionsExtensions.cs index 5c785301f..16f716666 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileSystemOptionsExtensions.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileSystemOptionsExtensions.cs @@ -9,9 +9,10 @@ public static class FileSystemOptionsExtensions { public static void SetupValidators(this VirtualFileSystemOptions fileSystemOptions, FileSystemSpecifics specifics) { - fileSystemOptions.HealthStatistics.FileValidator ??= new FileValidator(specifics); + fileSystemOptions.HealthStatistics.FileContentValidator ??= new FileContentValidator(specifics); fileSystemOptions.HealthStatistics.FolderValidator ??= new FolderValidator(specifics); - fileSystemOptions.HealthStatistics.StructureValidator ??= new StructureValidator(specifics, fileSystemOptions.HealthStatistics.FileValidator, fileSystemOptions.HealthStatistics.FolderValidator); + fileSystemOptions.HealthStatistics.StructureValidator ??= new StructureValidator(specifics, fileSystemOptions.HealthStatistics.FolderValidator); + fileSystemOptions.HealthStatistics.StructureContentsValidator ??= new StructureContentsValidator(specifics, fileSystemOptions.HealthStatistics.FileContentValidator, fileSystemOptions.HealthStatistics.FolderValidator); } public static IDictionary AppendContract(this IDictionary options, IDisposable unlockContract) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.FileContent.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.FileContent.cs new file mode 100644 index 000000000..c5af889cd --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.FileContent.cs @@ -0,0 +1,256 @@ +using OwlCore.Storage; +using SecureFolderFS.Core.Cryptography; +using SecureFolderFS.Core.FileSystem.Buffers; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.Extensions; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; + +namespace SecureFolderFS.Core.FileSystem.Helpers.Health +{ + public static partial class HealthHelpers + { + /// + /// Represents the result of a file content validation. + /// + public sealed class FileContentValidationResult + { + /// + /// Gets whether the file header is valid and readable. + /// + public bool IsHeaderValid { get; init; } + + /// + /// Gets the list of chunk numbers that failed validation. + /// + public IReadOnlyList CorruptedChunks { get; init; } = Array.Empty(); + + /// + /// Gets whether the file is recoverable (header is valid but some chunks are corrupted). + /// + public bool IsRecoverable => IsHeaderValid && CorruptedChunks.Count > 0; + + /// + /// Gets whether the file is irrecoverable (header is invalid). + /// + public bool IsIrrecoverable => !IsHeaderValid; + + /// + /// Gets whether the file is completely valid. + /// + public bool IsValid => IsHeaderValid && CorruptedChunks.Count == 0; + } + + /// + /// Validates the contents of an encrypted file, checking both header and chunk integrity. + /// + /// The encrypted file to validate. + /// The security object for decryption. + /// A that cancels this action. + /// A containing the validation results. + public static async Task ValidateFileContentsAsync(IFile file, Security security, CancellationToken cancellationToken = default) + { + await using var stream = await file.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken); + + // Check if file is empty or too small to have a header + if (stream.Length < security.HeaderCrypt.HeaderCiphertextSize) + { + return new FileContentValidationResult + { + IsHeaderValid = stream.Length == 0, // Empty files are considered valid + CorruptedChunks = Array.Empty() + }; + } + + // Try to read and decrypt the header + var headerBuffer = new HeaderBuffer(security.HeaderCrypt.HeaderPlaintextSize); + try + { + var ciphertextHeader = new byte[security.HeaderCrypt.HeaderCiphertextSize]; + var read = await stream.ReadAsync(ciphertextHeader, cancellationToken); + + if (read < ciphertextHeader.Length) + { + return new FileContentValidationResult + { + IsHeaderValid = false, + CorruptedChunks = Array.Empty() + }; + } + + var headerDecrypted = security.HeaderCrypt.DecryptHeader(ciphertextHeader, headerBuffer); + if (!headerDecrypted) + { + return new FileContentValidationResult + { + IsHeaderValid = false, + CorruptedChunks = Array.Empty() + }; + } + } + catch (ArgumentException) + { + return new FileContentValidationResult + { + IsHeaderValid = false, + CorruptedChunks = Array.Empty() + }; + } + catch (CryptographicException) + { + return new FileContentValidationResult + { + IsHeaderValid = false, + CorruptedChunks = Array.Empty() + }; + } + + // Header is valid, now check chunks + var corruptedChunks = new List(); + var ciphertextChunkSize = security.ContentCrypt.ChunkCiphertextSize; + var plaintextChunkSize = security.ContentCrypt.ChunkPlaintextSize; + var ciphertextChunk = ArrayPool.Shared.Rent(ciphertextChunkSize); + var plaintextChunk = ArrayPool.Shared.Rent(plaintextChunkSize); + + try + { + long chunkNumber = 0; + + while (stream.Position < stream.Length) + { + cancellationToken.ThrowIfCancellationRequested(); + + var read = await stream.ReadAsync(ciphertextChunk.AsMemory(0, ciphertextChunkSize), cancellationToken); + if (read == 0) + break; + + // Check if chunk first bytes are all zeros (extended chunk, skip validation) + var chunkReservedSize = Math.Min(read, security.ContentCrypt.ChunkFirstReservedSize); + var isAllZeros = true; + for (var i = 0; i < chunkReservedSize; i++) + { + if (ciphertextChunk[i] != 0) + { + isAllZeros = false; + break; + } + } + + if (!isAllZeros) + { + // Try to decrypt the chunk + var decryptResult = security.ContentCrypt.DecryptChunk( + ciphertextChunk.AsSpan(0, read), + chunkNumber, + headerBuffer, + plaintextChunk); + + if (!decryptResult) + { + corruptedChunks.Add(chunkNumber); + } + } + + chunkNumber++; + } + } + finally + { + CryptographicOperations.ZeroMemory(ciphertextChunk.AsSpan(0, ciphertextChunkSize)); + CryptographicOperations.ZeroMemory(plaintextChunk.AsSpan(0, plaintextChunkSize)); + ArrayPool.Shared.Return(ciphertextChunk); + ArrayPool.Shared.Return(plaintextChunk); + } + + return new FileContentValidationResult + { + IsHeaderValid = true, + CorruptedChunks = corruptedChunks + }; + } + + /// + /// Repairs corrupted chunks in a file by zeroing them out. + /// + /// The file to repair. + /// The security object for encryption. + /// The list of chunk numbers that are corrupted. + /// A that cancels this action. + /// A that represents the asynchronous operation. + public static async Task RepairFileChunksAsync(IFile file, Security security, IReadOnlyList corruptedChunks, CancellationToken cancellationToken = default) + { + if (corruptedChunks.Count == 0) + return Result.Success; + + try + { + await using var stream = await file.OpenStreamAsync(FileAccess.ReadWrite, FileShare.None, cancellationToken); + + var headerSize = security.HeaderCrypt.HeaderCiphertextSize; + var ciphertextChunkSize = security.ContentCrypt.ChunkCiphertextSize; + + // Zero buffer for writing + var zeroBuffer = new byte[ciphertextChunkSize]; + + foreach (var chunkNumber in corruptedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + var chunkPosition = headerSize + (chunkNumber * ciphertextChunkSize); + + // Check if position is within file bounds + if (chunkPosition >= stream.Length) + continue; + + // Calculate how much to write (might be less for the last chunk) + var remainingBytes = stream.Length - chunkPosition; + var bytesToWrite = (int)Math.Min(ciphertextChunkSize, remainingBytes); + + // Seek to chunk position and zero it out + stream.Position = chunkPosition; + await stream.WriteAsync(zeroBuffer.AsMemory(0, bytesToWrite), cancellationToken); + } + + await stream.FlushAsync(cancellationToken); + return Result.Success; + } + catch (Exception ex) + { + return Result.Failure(ex); + } + } + + /// + /// Deletes a file that has an irrecoverable header. + /// + /// The file to delete. + /// A that cancels this action. + /// A that represents the asynchronous operation. + public static async Task DeleteIrrecoverableFileAsync(IFile file, CancellationToken cancellationToken = default) + { + try + { + if (file is not IChildFile childFile) + return Result.Failure(new InvalidOperationException("File is not a child file.")); + + var parent = await childFile.GetParentAsync(cancellationToken); + if (parent is not IModifiableFolder modifiableFolder) + return Result.Failure(new InvalidOperationException("Parent folder does not support deletion.")); + + await modifiableFolder.DeleteAsync(childFile, cancellationToken); + return Result.Success; + } + catch (Exception ex) + { + return Result.Failure(ex); + } + } + } +} + diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Cipher.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Cipher.cs index acdcc02d4..6a2b77023 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Cipher.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Cipher.cs @@ -39,7 +39,7 @@ public static partial class AbstractPathHelpers return Path.Combine(specifics.ContentFolder.Id, finalPath); } - public static async Task GetCiphertextItemAsync(IStorableChild plaintextStorable, FileSystemSpecifics specifics, CancellationToken cancellationToken) + public static async Task GetCiphertextItemAsync(IStorableChild plaintextStorable, IFolder virtualizedRoot, FileSystemSpecifics specifics, CancellationToken cancellationToken) { if (specifics.Security.NameCrypt is null) return plaintextStorable; @@ -49,6 +49,10 @@ public static partial class AbstractPathHelpers while (await currentStorable.GetParentAsync(cancellationToken).ConfigureAwait(false) is IChildFolder currentParent) { + // If the parent is deeper than the virtualized root, we can stop + if (!currentParent.Id.Contains(virtualizedRoot.Id)) + break; + folderChain.Insert(0, currentParent); currentStorable = currentParent; } @@ -104,17 +108,17 @@ public static partial class AbstractPathHelpers /// Encrypts the provided . /// /// The name to encrypt. - /// The ciphertext parent folder. + /// The ciphertext parent folder. /// The instance associated with the item. /// A that cancels this action. /// A that represents the asynchronous operation. Value is an encrypted name. - public static async Task EncryptNameAsync(string plaintextName, IFolder parentFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + public static async Task EncryptNameAsync(string plaintextName, IFolder ciphertextParentFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) { if (specifics.Security.NameCrypt is null) return plaintextName; var directoryId = AllocateDirectoryId(specifics.Security, plaintextName); - var result = await GetDirectoryIdAsync(parentFolder, specifics, directoryId, cancellationToken); + var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, directoryId, cancellationToken); return specifics.Security.NameCrypt.EncryptName(plaintextName, result ? directoryId : ReadOnlySpan.Empty) + FileSystem.Constants.Names.ENCRYPTED_FILE_EXTENSION; } @@ -123,11 +127,11 @@ public static async Task EncryptNameAsync(string plaintextName, IFolder /// Decrypts the provided . /// /// The name to decrypt. - /// The ciphertext parent folder. + /// The ciphertext parent folder. /// The instance associated with the item. /// A that cancels this action. /// A that represents the asynchronous operation. Value is a decrypted name. - public static async Task DecryptNameAsync(string ciphertextName, IFolder parentFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + public static async Task DecryptNameAsync(string ciphertextName, IFolder ciphertextParentFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) { try { @@ -135,7 +139,7 @@ public static async Task EncryptNameAsync(string plaintextName, IFolder return ciphertextName; var directoryId = AllocateDirectoryId(specifics.Security, ciphertextName); - var result = await GetDirectoryIdAsync(parentFolder, specifics, directoryId, cancellationToken); + var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, directoryId, cancellationToken); return specifics.Security.NameCrypt.DecryptName(Path.GetFileNameWithoutExtension(ciphertextName), result ? directoryId : ReadOnlySpan.Empty); } 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 be843da16..08c4cfa75 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 @@ -1,20 +1,26 @@ -using OwlCore.Storage; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; using SecureFolderFS.Core.FileSystem.DataModels; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.Extensions; -using SecureFolderFS.Storage.Renamable; using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Abstract { public static partial class AbstractRecycleBinHelpers { + /// + /// The threshold in seconds for detecting recently created files. + /// Files created within this threshold will be deleted immediately instead of recycled. + /// This helps work around macOS Finder behavior during copy operations. + /// + private const int RECENT_FILE_THRESHOLD_MS = 3000; + public static async Task GetDestinationFolderAsync(IStorableChild item, FileSystemSpecifics specifics, IAsyncSerializer streamSerializer, CancellationToken cancellationToken = default) { // Get recycle bin @@ -60,8 +66,8 @@ public static async Task RestoreAsync(IStorableChild item, IModifiableFolder des // Get recycle bin var recycleBin = await TryGetRecycleBinAsync(specifics, cancellationToken); - if (recycleBin is not IRenamableFolder renamableRecycleBin) - throw new UnauthorizedAccessException("The recycle bin is not renamable."); + if (recycleBin is not IModifiableFolder modifiableRecycleBin) + throw new UnauthorizedAccessException("The recycle bin is not modifiable."); // Deserialize configuration var deserialized = await GetItemDataModelAsync(item, recycleBin, streamSerializer, cancellationToken); @@ -76,11 +82,8 @@ public static async Task RestoreAsync(IStorableChild item, IModifiableFolder des var plaintextName = specifics.Security.NameCrypt?.DecryptName(Path.GetFileNameWithoutExtension(deserialized.OriginalName), deserialized.DirectoryId) ?? deserialized.OriginalName; var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(plaintextName, destinationFolder, specifics, cancellationToken); - // Rename the item to correct name - var renamedItem = await renamableRecycleBin.RenameAsync(item, ciphertextName, cancellationToken); - - // Move item to destination - _ = await destinationFolder.MoveStorableFromAsync(renamedItem, renamableRecycleBin, false, cancellationToken); + // Rename and move item to destination + _ = await destinationFolder.MoveStorableFromAsync(item, modifiableRecycleBin, false, ciphertextName, null, cancellationToken); } else { @@ -88,101 +91,141 @@ public static async Task RestoreAsync(IStorableChild item, IModifiableFolder des // The same name could be used since the Directory IDs match // TODO: Check if the Directory IDs actually match and fallback to above method if not - // Rename the item to correct name - var renamedItem = await renamableRecycleBin.RenameAsync(item, deserialized.OriginalName, cancellationToken); - - // Move item to destination - _ = await destinationFolder.MoveStorableFromAsync(renamedItem, renamableRecycleBin, false, cancellationToken); + // Rename and move item to destination + _ = await destinationFolder.MoveStorableFromAsync(item, modifiableRecycleBin, false, deserialized.OriginalName, null, cancellationToken); } // Delete old configuration file var configurationFile = await recycleBin.GetFileByNameAsync($"{item.Name}.json", cancellationToken); - await renamableRecycleBin.DeleteAsync(configurationFile, cancellationToken); + await modifiableRecycleBin.DeleteAsync(configurationFile, cancellationToken); // Check if the item had any size if (deserialized.Size is not ({ } size and > 0L)) return; // Update occupied size - var occupiedSize = await GetOccupiedSizeAsync(renamableRecycleBin, cancellationToken); + var occupiedSize = await GetOccupiedSizeAsync(modifiableRecycleBin, cancellationToken); var newSize = occupiedSize - size; - await SetOccupiedSizeAsync(renamableRecycleBin, newSize, cancellationToken); + await SetOccupiedSizeAsync(modifiableRecycleBin, newSize, cancellationToken); } public static async Task DeleteOrRecycleAsync( - IModifiableFolder sourceFolder, - IStorableChild item, + IModifiableFolder ciphertextSourceFolder, + IStorableChild ciphertextItem, FileSystemSpecifics specifics, IAsyncSerializer streamSerializer, long sizeHint = -1L, + bool deleteImmediately = false, CancellationToken cancellationToken = default) { if (specifics.Options.IsReadOnly) throw FileSystemExceptions.FileSystemReadOnly; - if (!specifics.Options.IsRecycleBinEnabled()) + if (!specifics.Options.IsRecycleBinEnabled() || deleteImmediately) { - await sourceFolder.DeleteAsync(item, cancellationToken); + await ciphertextSourceFolder.DeleteAsync(ciphertextItem, cancellationToken); return; } + if (OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst()) + { + var parentFolder = await ciphertextItem.GetParentAsync(cancellationToken); + if (parentFolder is not null) + { + var plaintextName = await AbstractPathHelpers.DecryptNameAsync(ciphertextItem.Name, parentFolder, specifics, cancellationToken) ?? string.Empty; + if (plaintextName == ".DS_Store" || plaintextName.StartsWith("._", StringComparison.Ordinal)) + { + // .DS_Store and Apple Double files are not supported by the recycle bin, delete immediately + await ciphertextSourceFolder.DeleteAsync(ciphertextItem, cancellationToken); + return; + } + } + + // Check if the file was recently created (likely part of a copy operation) + // On macOS, Finder creates files and immediately deletes them during copy operations + if (await IsRecentlyCreatedAsync(ciphertextItem, cancellationToken)) + { + await ciphertextSourceFolder.DeleteAsync(ciphertextItem, cancellationToken); + return; + } + } + // Get recycle bin var recycleBin = await GetOrCreateRecycleBinAsync(specifics, cancellationToken); - if (recycleBin is not IRenamableFolder renamableRecycleBin) - throw new UnauthorizedAccessException("The recycle bin is not renamable."); + if (recycleBin is not IModifiableFolder modifiableRecycleBin) + throw new UnauthorizedAccessException("The recycle bin is not modifiable."); if (sizeHint < 0L && specifics.Options.RecycleBinSize > 0L) { - sizeHint = item switch + sizeHint = ciphertextItem switch { - IFile file => await file.GetSizeAsync(cancellationToken), - IFolder folder => await folder.GetSizeAsync(cancellationToken), + IFile file => await file.GetSizeAsync(cancellationToken) ?? 0L, + IFolder folder => await folder.GetSizeAsync(cancellationToken) ?? 0L, _ => 0L }; - var occupiedSize = await GetOccupiedSizeAsync(renamableRecycleBin, cancellationToken); + var occupiedSize = await GetOccupiedSizeAsync(modifiableRecycleBin, cancellationToken); var availableSize = specifics.Options.RecycleBinSize - occupiedSize; if (availableSize < sizeHint) { - await sourceFolder.DeleteAsync(item, cancellationToken); + await ciphertextSourceFolder.DeleteAsync(ciphertextItem, cancellationToken); return; } } // Get source Directory ID - var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, sourceFolder.Id); - var directoryIdResult = await AbstractPathHelpers.GetDirectoryIdAsync(sourceFolder, specifics, directoryId, cancellationToken); + var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, ciphertextSourceFolder.Id); + var directoryIdResult = await AbstractPathHelpers.GetDirectoryIdAsync(ciphertextSourceFolder, specifics, directoryId, cancellationToken); - // Move and rename item + // Rename and move item var guid = Guid.NewGuid().ToString(); - var movedItem = await renamableRecycleBin.MoveStorableFromAsync(item, sourceFolder, false, cancellationToken); - _ = await renamableRecycleBin.RenameAsync(movedItem, guid, cancellationToken); + _ = await modifiableRecycleBin.MoveStorableFromAsync(ciphertextItem, ciphertextSourceFolder, false, guid, null, cancellationToken); // Create configuration file - var configurationFile = await renamableRecycleBin.CreateFileAsync($"{guid}.json", false, cancellationToken); - await using var configurationStream = await configurationFile.OpenWriteAsync(cancellationToken); - - // Serialize configuration data model - await using var serializedStream = await streamSerializer.SerializeAsync( - new RecycleBinItemDataModel() - { - OriginalName = item.Name, - ParentPath = sourceFolder.Id.Replace(specifics.ContentFolder.Id, string.Empty).Replace(Path.DirectorySeparatorChar, '/'), - DirectoryId = directoryIdResult ? directoryId : [], - DeletionTimestamp = DateTime.Now, - Size = sizeHint - }, cancellationToken); - - // Write to destination stream - await serializedStream.CopyToAsync(configurationStream, cancellationToken); - await configurationStream.FlushAsync(cancellationToken); + var configurationFile = await modifiableRecycleBin.CreateFileAsync($"{guid}.json", false, cancellationToken); + await using (var configurationStream = await configurationFile.OpenWriteAsync(cancellationToken)) + { + // Serialize configuration data model + await using var serializedStream = await streamSerializer.SerializeAsync( + new RecycleBinItemDataModel() + { + OriginalName = ciphertextItem.Name, + ParentPath = ciphertextSourceFolder.Id.Replace(specifics.ContentFolder.Id, string.Empty).Replace(Path.DirectorySeparatorChar, '/'), + DirectoryId = directoryIdResult ? directoryId : [], + DeletionTimestamp = DateTime.Now, + Size = sizeHint + }, cancellationToken); + + // Write to destination stream + await serializedStream.CopyToAsync(configurationStream, cancellationToken); + await configurationStream.FlushAsync(cancellationToken); + } // Update occupied size if (specifics.Options.IsRecycleBinEnabled()) { - var occupiedSize = await GetOccupiedSizeAsync(renamableRecycleBin, cancellationToken); + var occupiedSize = await GetOccupiedSizeAsync(modifiableRecycleBin, cancellationToken); var newSize = occupiedSize + sizeHint; - await SetOccupiedSizeAsync(renamableRecycleBin, newSize, cancellationToken); + await SetOccupiedSizeAsync(modifiableRecycleBin, newSize, cancellationToken); + } + } + + private static async Task IsRecentlyCreatedAsync(IStorable storable, CancellationToken cancellationToken) + { + try + { + var dateCreated = await storable.GetDateCreatedAsync(cancellationToken); + if (dateCreated is null) + return false; + + var dateCreatedUtc = dateCreated.Value.ToUniversalTime(); + var timeSinceCreation = DateTime.UtcNow - dateCreatedUtc; + return timeSinceCreation.Seconds <= RECENT_FILE_THRESHOLD_MS / 1000; + } + catch + { + // If we can't determine creation time, assume it's not recent + return false; } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Shared.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Shared.cs index 21a3f473c..6a30123f3 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Shared.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Shared.cs @@ -1,12 +1,13 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; using OwlCore.Storage; using SecureFolderFS.Core.FileSystem.DataModels; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.Extensions; -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; using SecureFolderFS.Storage.VirtualFileSystem; namespace SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Abstract @@ -15,18 +16,30 @@ public static partial class AbstractRecycleBinHelpers { public static async Task GetOccupiedSizeAsync(IModifiableFolder recycleBin, CancellationToken cancellationToken = default) { - var recycleBinConfig = await recycleBin.CreateFileAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, false, cancellationToken); - var text = await recycleBinConfig.ReadAllTextAsync(null, cancellationToken); - if (!long.TryParse(text, out var value)) + var recycleBinConfig = await recycleBin.TryGetFileByNameAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, cancellationToken); + recycleBinConfig ??= await recycleBin.CreateFileAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, false, cancellationToken); + + await using var configStream = await recycleBinConfig.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken); + var deserialized = await StreamSerializer.Instance.TryDeserializeAsync(configStream, cancellationToken); + if (deserialized is null) return 0L; - return Math.Max(0L, value); + return Math.Max(0L, deserialized.OccupiedSize); } public static async Task SetOccupiedSizeAsync(IModifiableFolder recycleBin, long value, CancellationToken cancellationToken = default) { - var recycleBinConfig = await recycleBin.CreateFileAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, false, cancellationToken); - await recycleBinConfig.WriteAllTextAsync(Math.Max(0L, value).ToString(), null, cancellationToken); + var recycleBinConfig = await recycleBin.TryGetFileByNameAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, cancellationToken); + recycleBinConfig ??= await recycleBin.CreateFileAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, false, cancellationToken); + + await using var configStream = await recycleBinConfig.OpenWriteAsync(cancellationToken); + await using var serialized = await StreamSerializer.Instance.SerializeAsync(new RecycleBinDataModel() + { + OccupiedSize = Math.Max(0L, value) + }, cancellationToken); + + await serialized.CopyToAsync(configStream, cancellationToken); + await configStream.FlushAsync(cancellationToken); } public static async Task GetItemDataModelAsync(IStorableChild item, IFolder recycleBin, IAsyncSerializer streamSerializer, CancellationToken cancellationToken = default) @@ -37,7 +50,7 @@ public static async Task GetItemDataModelAsync(IStorabl : (IFile)item; // Read configuration file - await using var configurationStream = await configurationFile.OpenReadAsync(cancellationToken); + await using var configurationStream = await configurationFile.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken); // Deserialize configuration var deserialized = await streamSerializer.DeserializeAsync(configurationStream, cancellationToken); @@ -47,18 +60,6 @@ public static async Task GetItemDataModelAsync(IStorabl return deserialized; } - public static async Task TryGetRecycleBinAsync(FileSystemSpecifics specifics, CancellationToken cancellationToken = default) - { - try - { - return await specifics.ContentFolder.GetFolderByNameAsync(Constants.Names.RECYCLE_BIN_NAME, cancellationToken); - } - catch (Exception) - { - return null; - } - } - public static async Task GetOrCreateRecycleBinAsync(FileSystemSpecifics specifics, CancellationToken cancellationToken = default) { var recycleBin = await TryGetRecycleBinAsync(specifics, cancellationToken); @@ -70,5 +71,10 @@ public static async Task GetOrCreateRecycleBinAsync(FileSystemSpecifics return await modifiableFolder.CreateFolderAsync(Constants.Names.RECYCLE_BIN_NAME, false, cancellationToken); } + + public static async Task TryGetRecycleBinAsync(FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + { + return await specifics.ContentFolder.TryGetFolderByNameAsync(Constants.Names.RECYCLE_BIN_NAME, cancellationToken); + } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs index 223bdc5bb..623a384fe 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs @@ -25,6 +25,18 @@ public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics sp return; } + if (OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst()) + { + var plaintextPath = NativePathHelpers.GetPlaintextPath(ciphertextPath, specifics); + var plaintextName = Path.GetFileName(plaintextPath) ?? string.Empty; + if (plaintextName == ".DS_Store" || plaintextName.StartsWith("._", StringComparison.Ordinal)) + { + // .DS_Store and Apple Double files are not supported by the recycle bin, delete immediately + DeleteImmediately(ciphertextPath, storableType); + return; + } + } + var recycleBinPath = Path.Combine(specifics.ContentFolder.Id, Constants.Names.RECYCLE_BIN_NAME); _ = Directory.CreateDirectory(recycleBinPath); @@ -56,22 +68,23 @@ public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics sp Directory.Move(ciphertextPath, destinationPath); // Create configuration file - using var configurationStream = File.Create($"{destinationPath}.json"); - - // Serialize configuration data model - using var serializedStream = StreamSerializer.Instance.SerializeAsync( - new RecycleBinItemDataModel() - { - OriginalName = Path.GetFileName(ciphertextPath), - ParentPath = Path.GetDirectoryName(ciphertextPath)?.Replace(specifics.ContentFolder.Id, string.Empty).Replace(Path.DirectorySeparatorChar, '/') ?? string.Empty, - DirectoryId = directoryIdResult ? directoryId : [], - DeletionTimestamp = DateTime.Now, - Size = sizeHint - }).ConfigureAwait(false).GetAwaiter().GetResult(); - - // Write to destination stream - serializedStream.CopyTo(configurationStream); - serializedStream.Flush(); + using (var configurationStream = File.Create($"{destinationPath}.json")) + { + // Serialize configuration data model + using var serializedStream = StreamSerializer.Instance.SerializeAsync( + new RecycleBinItemDataModel() + { + OriginalName = Path.GetFileName(ciphertextPath), + ParentPath = Path.GetDirectoryName(ciphertextPath)?.Replace(specifics.ContentFolder.Id, string.Empty).Replace(Path.DirectorySeparatorChar, '/') ?? string.Empty, + DirectoryId = directoryIdResult ? directoryId : [], + DeletionTimestamp = DateTime.Now, + Size = sizeHint + }).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Write to destination stream + serializedStream.CopyTo(configurationStream); + serializedStream.Flush(); + } // Update occupied size if (specifics.Options.IsRecycleBinEnabled()) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Shared.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Shared.cs index 2fa0eddcb..4a9f8909d 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Shared.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Shared.cs @@ -3,6 +3,9 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using SecureFolderFS.Core.FileSystem.DataModels; +using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Models; namespace SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native { @@ -25,7 +28,7 @@ public static long GetFolderSizeRecursive(string path) var fileInfo = new FileInfo(file); return localTotal + fileInfo.Length; } - catch + catch (Exception) { // Ignore errors (e.g., access denied) return localTotal; @@ -51,14 +54,13 @@ public static long GetFolderSizeRecursive(string path) public static long GetOccupiedSize(FileSystemSpecifics specifics) { var configPath = Path.Combine(specifics.ContentFolder.Id, Constants.Names.RECYCLE_BIN_NAME, Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME); - using var configStream = specifics.Options.IsReadOnly ? File.OpenRead(configPath) : (!File.Exists(configPath) ? File.Create(configPath) : File.OpenRead(configPath)); - using var streamReader = new StreamReader(configStream); + using var configStream = File.Open(configPath, specifics.Options.IsReadOnly ? FileMode.Open : FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read); - var text = streamReader.ReadToEnd(); - if (!long.TryParse(text, out var value)) + var deserialized = StreamSerializer.Instance.TryDeserializeAsync(configStream).ConfigureAwait(false).GetAwaiter().GetResult(); + if (deserialized is null) return 0L; - return Math.Max(0L, value); + return Math.Max(0L, deserialized.OccupiedSize); } public static void SetOccupiedSize(FileSystemSpecifics specifics, long value) @@ -68,10 +70,13 @@ public static void SetOccupiedSize(FileSystemSpecifics specifics, long value) var configPath = Path.Combine(specifics.ContentFolder.Id, Constants.Names.RECYCLE_BIN_NAME, Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME); using var configStream = !File.Exists(configPath) ? File.Create(configPath) : File.OpenWrite(configPath); - using var streamWriter = new StreamWriter(configStream); + using var serialized = StreamSerializer.Instance.SerializeAsync(new RecycleBinDataModel() + { + OccupiedSize = Math.Max(0L, value) + }).ConfigureAwait(false).GetAwaiter().GetResult(); - var text = Math.Max(0L, value).ToString(); - streamWriter.Write(text); + serialized.CopyTo(configStream); + configStream.Flush(); } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/IMountableFileSystem.cs b/src/Core/SecureFolderFS.Core.FileSystem/IMountableFileSystem.cs index 887f94bab..39289ccbd 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/IMountableFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/IMountableFileSystem.cs @@ -16,6 +16,6 @@ public interface IMountableFileSystem /// Options specifying mount operation. /// A that cancels this action. /// A that represents the asynchronous operation. If successful, returns an instance of of the mounted file system; otherwise false. - Task MountAsync(MountOptions mountOptions, CancellationToken cancellationToken = default); + Task MountAsync(MountOptions mountOptions, CancellationToken cancellationToken = default); } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/LocalFileSystem.cs b/src/Core/SecureFolderFS.Core.FileSystem/LocalFileSystem.cs index 7335d5396..77b05e763 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/LocalFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/LocalFileSystem.cs @@ -14,8 +14,8 @@ namespace SecureFolderFS.Core.FileSystem { - /// - public sealed class LocalFileSystem : IFileSystem + /// + public sealed class LocalFileSystem : IFileSystemInfo { /// public string Id { get; } = Constants.FileSystem.FS_ID; @@ -30,18 +30,18 @@ public Task GetStatusAsync(CancellationToken cancellatio } /// - public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) + public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) { await Task.CompletedTask; if (unlockContract is not IWrapper wrapper) throw new ArgumentException($"The {nameof(unlockContract)} is invalid."); - var fileSystemOptions = VirtualFileSystemOptions.ToOptions(options.AppendContract(unlockContract), () => new HealthStatistics(), static () => new FileSystemStatistics()); + var fileSystemOptions = VirtualFileSystemOptions.ToOptions(options.AppendContract(unlockContract), static () => new HealthStatistics(), static () => new FileSystemStatistics()); var specifics = FileSystemSpecifics.CreateNew(wrapper.Inner, folder, fileSystemOptions); fileSystemOptions.SetupValidators(specifics); var storageRoot = new CryptoFolder(Path.DirectorySeparatorChar.ToString(), specifics.ContentFolder, specifics); - return new LocalVFSRoot(specifics, storageRoot, specifics); + return new LocalVfsRoot(specifics, storageRoot, specifics); } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/LocalVFSRoot.cs b/src/Core/SecureFolderFS.Core.FileSystem/LocalVfsRoot.cs similarity index 75% rename from src/Core/SecureFolderFS.Core.FileSystem/LocalVFSRoot.cs rename to src/Core/SecureFolderFS.Core.FileSystem/LocalVfsRoot.cs index 3943cb04b..0c10056d4 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/LocalVFSRoot.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/LocalVfsRoot.cs @@ -5,16 +5,16 @@ namespace SecureFolderFS.Core.FileSystem { - /// - public sealed class LocalVFSRoot : VFSRoot + /// + public sealed class LocalVfsRoot : VfsRoot { private readonly IDisposable _disposable; /// public override string FileSystemName { get; } = Constants.LOCAL_FILE_SYSTEM_NAME; - public LocalVFSRoot(IDisposable disposable, IFolder storageRoot, FileSystemSpecifics specifics) - : base(storageRoot, specifics) + public LocalVfsRoot(IDisposable disposable, IFolder storageRoot, FileSystemSpecifics specifics) + : base(storageRoot, storageRoot, specifics) { _disposable = disposable; } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFile.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFile.cs index ee542223a..349fb30ec 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFile.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFile.cs @@ -1,17 +1,28 @@ -using OwlCore.Storage; -using SecureFolderFS.Core.FileSystem.Storage.StorageProperties; -using SecureFolderFS.Storage.StorageProperties; -using SecureFolderFS.Storage.VirtualFileSystem; -using System; +using System; using System.IO; using System.Threading; using System.Threading.Tasks; +using OwlCore.Storage; +using SecureFolderFS.Core.FileSystem.Storage.StorageProperties; +using SecureFolderFS.Storage.Extensions; +using SecureFolderFS.Storage.FileShareOptions; +using SecureFolderFS.Storage.StorageProperties; +using SecureFolderFS.Storage.VirtualFileSystem; namespace SecureFolderFS.Core.FileSystem.Storage { /// - public class CryptoFile : CryptoStorable, IChildFile + public class CryptoFile : CryptoStorable, IFileOpenShare, IChildFile, ICreatedAt, ILastModifiedAt, ISizeOf { + /// + public ICreatedAtProperty CreatedAt => field ??= new CryptoCreatedAtProperty(Id, (Inner as ICreatedAt)?.CreatedAt); + + /// + public ILastModifiedAtProperty LastModifiedAt => field ??= new CryptoLastModifiedAtProperty(Id, (Inner as ILastModifiedAt)?.LastModifiedAt); + + /// + public ISizeOfProperty SizeOf => field ??= new CryptoSizeOfProperty(Id, specifics, (Inner as ISizeOf)?.SizeOf); + public CryptoFile(string plaintextId, IFile inner, FileSystemSpecifics specifics, CryptoFolder? parent = null) : base(plaintextId, inner, specifics, parent) { @@ -31,16 +42,17 @@ public virtual async Task OpenStreamAsync(FileAccess access, Cancellatio return CreatePlaintextStream(stream, readingStream); } - /// - public override async Task GetPropertiesAsync() + public async Task OpenStreamAsync(FileAccess accessMode, FileShare shareMode, CancellationToken cancellationToken = default) { - if (Inner is not IStorableProperties storableProperties) - throw new NotSupportedException($"Properties on {nameof(CryptoFile)}.{nameof(Inner)} are not supported."); + if (specifics.Options.IsReadOnly && accessMode.HasFlag(FileAccess.Write)) + throw FileSystemExceptions.FileSystemReadOnly; - var innerProperties = await storableProperties.GetPropertiesAsync(); - properties ??= new CryptoFileProperties(specifics, innerProperties); + var stream = await Inner.OpenStreamAsync(accessMode, shareMode, cancellationToken); + if (stream.CanRead || stream is { CanSeek: true, Length: <= 0 }) + return CreatePlaintextStream(stream, null); - return properties; + var readingStream = await Inner.OpenStreamAsync(FileAccess.Read, shareMode, cancellationToken); + return CreatePlaintextStream(stream, readingStream); } /// diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs index 7550bc7cd..ce79be3a3 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs @@ -1,32 +1,47 @@ -using OwlCore.Storage; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Abstract; using SecureFolderFS.Core.FileSystem.Storage.StorageProperties; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.Recyclable; using SecureFolderFS.Storage.Renamable; -using SecureFolderFS.Storage.StorageProperties; -using System; -using System.Collections.Generic; -using System.IO; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Core.FileSystem.Storage { - // TODO(ns): Add move and copy support /// - public class CryptoFolder : CryptoStorable, IChildFolder, IGetFirstByName, IRenamableFolder, IRecyclableFolder + public class CryptoFolder : CryptoStorable, + IChildFolder, + IGetFirstByName, + IMoveRenamedFrom, + ICreateRenamedCopyOf, + IRenamableFolder, + IRecyclableFolder, + ICreatedAt, + ILastModifiedAt { + /// + public ICreatedAtProperty CreatedAt => field ??= new CryptoCreatedAtProperty(Id, (Inner as ICreatedAt)?.CreatedAt); + + /// + public ILastModifiedAtProperty LastModifiedAt => field ??= new CryptoLastModifiedAtProperty(Id, (Inner as ILastModifiedAt)?.LastModifiedAt); + public CryptoFolder(string plaintextId, IFolder inner, FileSystemSpecifics specifics, CryptoFolder? parent = null) : base(plaintextId, inner, specifics, parent) { } /// - public async Task RenameAsync(IStorableChild storable, string newName, CancellationToken cancellationToken = default) + public virtual async Task RenameAsync(IStorableChild storable, string newName, CancellationToken cancellationToken = default) { if (Inner is not IRenamableFolder renamableFolder) throw new NotSupportedException("Renaming folder contents is not supported."); @@ -49,7 +64,7 @@ public async Task RenameAsync(IStorableChild storable, string ne } /// - public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public virtual async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) { await foreach (var item in Inner.GetItemsAsync(type, cancellationToken)) { @@ -70,7 +85,7 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = } /// - public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default) + public virtual async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default) { var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(name, Inner, specifics, cancellationToken); return await Inner.GetFirstByNameAsync(ciphertextName, cancellationToken) switch @@ -82,20 +97,20 @@ public async Task GetFirstByNameAsync(string name, CancellationT } /// - public Task GetFolderWatcherAsync(CancellationToken cancellationToken = default) + public virtual Task GetFolderWatcherAsync(CancellationToken cancellationToken = default) { // TODO(ns): Implement FolderWatcher for CryptoFolder - throw new NotImplementedException(); + throw new NotSupportedException(); } /// - public async Task DeleteAsync(IStorableChild item, CancellationToken cancellationToken = default) + public virtual async Task DeleteAsync(IStorableChild item, CancellationToken cancellationToken = default) { await DeleteAsync(item, -1L, false, cancellationToken); } /// - public async Task DeleteAsync(IStorableChild item, long sizeHint, bool deleteImmediately = false, CancellationToken cancellationToken = default) + public virtual async Task DeleteAsync(IStorableChild item, long sizeHint = -1L, bool deleteImmediately = false, CancellationToken cancellationToken = default) { if (Inner is not IModifiableFolder modifiableFolder) throw new NotSupportedException("Modifying folder contents is not supported."); @@ -107,12 +122,12 @@ public async Task DeleteAsync(IStorableChild item, long sizeHint, bool deleteImm if (deleteImmediately) { // Delete the ciphertext item - await modifiableFolder.DeleteAsync(item, cancellationToken); + await modifiableFolder.DeleteAsync(ciphertextItem, cancellationToken); } else { // Delete or recycle the ciphertext item - await AbstractRecycleBinHelpers.DeleteOrRecycleAsync(modifiableFolder, ciphertextItem, specifics, StreamSerializer.Instance, sizeHint, cancellationToken); + await AbstractRecycleBinHelpers.DeleteOrRecycleAsync(modifiableFolder, ciphertextItem, specifics, StreamSerializer.Instance, sizeHint, cancellationToken: cancellationToken); } // Remove deleted directory from cache @@ -121,7 +136,7 @@ public async Task DeleteAsync(IStorableChild item, long sizeHint, bool deleteImm } /// - public async Task CreateFolderAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) + public virtual async Task CreateFolderAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { if (Inner is not IModifiableFolder modifiableFolder) throw new NotSupportedException("Modifying folder contents is not supported."); @@ -135,7 +150,7 @@ public async Task CreateFolderAsync(string name, bool overwrite = var directoryIdFile = await createdModifiableFolder.CreateFileAsync(Constants.Names.DIRECTORY_ID_FILENAME, false, cancellationToken); await using var directoryIdStream = await directoryIdFile.OpenStreamAsync(FileAccess.Write, cancellationToken); - // Initialize directory with DirectoryID + // Initialize directory with Directory ID var directoryId = Guid.NewGuid().ToByteArray(); await directoryIdStream.WriteAsync(directoryId, cancellationToken); @@ -146,7 +161,7 @@ public async Task CreateFolderAsync(string name, bool overwrite = } /// - public async Task CreateFileAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) + public virtual async Task CreateFileAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { if (Inner is not IModifiableFolder modifiableFolder) throw new NotSupportedException("Modifying folder contents is not supported."); @@ -158,15 +173,105 @@ public async Task CreateFileAsync(string name, bool overwrite = fals } /// - public override async Task GetPropertiesAsync() + public virtual Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite, CancellationToken cancellationToken, + CreateCopyOfDelegate fallback) + { + return CreateCopyOfAsync(fileToCopy, overwrite, fileToCopy.Name, cancellationToken, (mf, f, ov, _, ct) => fallback(mf, f, ov, ct)); + } + + /// + public virtual async Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite, string newName, CancellationToken cancellationToken, + CreateRenamedCopyOfDelegate fallback) + { + if (Inner is not IModifiableFolder) + throw new NotSupportedException("Modifying folder contents is not supported."); + + if (Inner is not ICreateRenamedCopyOf createRenamedCopyOf || fileToCopy is not IChildFile fileToCopyChild) + return await fallback(this, fileToCopy, overwrite, newName, cancellationToken); + + // Get the ciphertext representation of the file to copy + var ciphertextFileToCopy = await GetCiphertextRepresentationAsync(fileToCopyChild, cancellationToken); + if (ciphertextFileToCopy is null) + return await fallback(this, fileToCopy, overwrite, newName, cancellationToken); + + // Encrypt the new name + var ciphertextNewName = await AbstractPathHelpers.EncryptNameAsync(newName, Inner, specifics, cancellationToken); + + // Copy the ciphertext file + var copiedCiphertextFile = await createRenamedCopyOf.CreateCopyOfAsync(ciphertextFileToCopy, overwrite, ciphertextNewName, cancellationToken); + return (IChildFile)Wrap(copiedCiphertextFile, newName); + } + + /// + public virtual Task MoveFromAsync(IChildFile fileToMove, IModifiableFolder source, bool overwrite, CancellationToken cancellationToken, + MoveFromDelegate fallback) + { + return MoveFromAsync(fileToMove, source, overwrite, fileToMove.Name, cancellationToken, (mf, f, src, ov, _, ct) => fallback(mf, f, src, ov, ct)); + } + + /// + public virtual async Task MoveFromAsync(IChildFile fileToMove, IModifiableFolder source, bool overwrite, string newName, + CancellationToken cancellationToken, MoveRenamedFromDelegate fallback) { - if (Inner is not IStorableProperties storableProperties) - throw new NotSupportedException($"Properties on {nameof(CryptoFolder)}.{nameof(Inner)} are not supported."); + if (Inner is not IModifiableFolder) + throw new NotSupportedException("Modifying folder contents is not supported."); + + if (source is not IChildFolder sourceFolder || Inner is not IMoveRenamedFrom moveRenamedFrom) + return await fallback(this, fileToMove, source, overwrite, newName, cancellationToken); + + // Get the ciphertext representation of the source folder + var ciphertextSource = await GetCiphertextRepresentationAsync(sourceFolder, cancellationToken); + if (ciphertextSource is not IModifiableFolder ciphertextSourceModifiableFolder) + return await fallback(this, fileToMove, source, overwrite, newName, cancellationToken); + + // Get the ciphertext representation of the file to move + var existingCiphertextName = await AbstractPathHelpers.EncryptNameAsync(fileToMove.Name, ciphertextSource, specifics, cancellationToken); + var ciphertextFileToMove = await ciphertextSource.TryGetFileByNameAsync(existingCiphertextName, cancellationToken); + if (ciphertextFileToMove is null) + return await fallback(this, fileToMove, source, overwrite, newName, cancellationToken); + + // Encrypt the new name + var newCiphertextName = await AbstractPathHelpers.EncryptNameAsync(newName, Inner, specifics, cancellationToken); + + // Move the ciphertext file + var movedCiphertextFile = await moveRenamedFrom.MoveFromAsync(ciphertextFileToMove, ciphertextSourceModifiableFolder, overwrite, newCiphertextName, cancellationToken, fallback); + return (IChildFile)Wrap(movedCiphertextFile, newName); + } + + /// + /// Retrieves the ciphertext representation of a given storable child item if available. + /// + /// The type of the storable item, which must implement . + /// The storable child item to retrieve the ciphertext representation for. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the ciphertext representation of the as . + protected virtual async Task GetCiphertextRepresentationAsync(TStorable item, + CancellationToken cancellationToken) + where TStorable : class, IStorableChild + { + var parentFolder = await item.GetParentAsync(cancellationToken); + if (parentFolder is null || parentFolder.Id == Path.DirectorySeparatorChar.ToString()) + { + // We're at the root + parentFolder ??= item as IFolder; + if (parentFolder is not IWrapper folderWrapper) + return null; + + if (folderWrapper.GetWrapperAt() is not { Inner: var ciphertextRoot }) + return null; + + var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(item.Name, ciphertextRoot, specifics, cancellationToken); + return await ciphertextRoot.TryGetFirstByNameAsync(ciphertextName, cancellationToken) as TStorable; + } + + if (parentFolder is not IWrapper parentFolderWrapper) + return null; - var innerProperties = await storableProperties.GetPropertiesAsync(); - properties ??= new CryptoFolderProperties(innerProperties); + if (parentFolderWrapper.GetWrapperAt() is not { Inner: var ciphertextParent }) + return null; - return properties; + var ciphertextName2 = await AbstractPathHelpers.EncryptNameAsync(item.Name, ciphertextParent, specifics, cancellationToken); + return await ciphertextParent.TryGetFirstByNameAsync(ciphertextName2, cancellationToken) as TStorable; } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs index afe432887..eb645ab0f 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs @@ -10,12 +10,11 @@ namespace SecureFolderFS.Core.FileSystem.Storage { /// - public abstract class CryptoStorable : IWrapper, IStorableChild, IStorableProperties + public abstract class CryptoStorable : IWrapper, IStorableChild where TCapability : IStorable { protected readonly CryptoFolder? parent; protected readonly FileSystemSpecifics specifics; - protected IBasicProperties? properties; /// public TCapability Inner { get; } @@ -67,9 +66,14 @@ protected CryptoStorable(string plaintextId, TCapability inner, FileSystemSpecif return (IFolder?)Wrap(ciphertextParent, plaintextName); } - /// - public abstract Task GetPropertiesAsync(); - + /// + /// Wraps an instance, associating the file with additional metadata + /// and creating a cryptographic representation of the file. + /// + /// The file to wrap. + /// An array of objects used to provide additional context for the wrapping operation. + /// The first object must be the plaintext name of the file as a . + /// An instance that represents the wrapped file with cryptographic integration. protected virtual IWrapper Wrap(IFile file, params object[] objects) { if (objects[0] is not string plaintextName) @@ -79,6 +83,12 @@ protected virtual IWrapper Wrap(IFile file, params object[] objects) return new CryptoFile(plaintextId, file, specifics, this as CryptoFolder); } + /// + /// Wraps the specified folder with additional capabilities and properties, enabling customization and enhanced functionality. + /// + /// The folder to wrap. + /// Additional arguments providing metadata or properties, with the first argument expected to be the plaintext name of the folder. + /// A wrapped instance of the folder with applied enhancements. protected virtual IWrapper Wrap(IFolder folder, params object[] objects) { if (objects[0] is not string plaintextName) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoCreatedAtProperty.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoCreatedAtProperty.cs new file mode 100644 index 000000000..ff9584d1f --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoCreatedAtProperty.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; + +namespace SecureFolderFS.Core.FileSystem.Storage.StorageProperties +{ + /// + public sealed class CryptoCreatedAtProperty : ICreatedAtProperty + { + private readonly ICreatedAtProperty? _createdAtProperty; + + /// + public string Id { get; } + + /// + public string Name { get; } + + public CryptoCreatedAtProperty(string id, ICreatedAtProperty? createdAtProperty) + { + _createdAtProperty = createdAtProperty; + Name = nameof(ICreatedAt.CreatedAt); + Id = $"{id}/{nameof(ICreatedAt.CreatedAt)}"; + } + + /// + public async Task GetValueAsync(CancellationToken cancellationToken = default) + { + if (_createdAtProperty is null) + return null; + + return await _createdAtProperty.GetValueAsync(cancellationToken); + } + } +} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoFileProperties.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoFileProperties.cs deleted file mode 100644 index c14377abd..000000000 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoFileProperties.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using OwlCore.Storage; -using SecureFolderFS.Storage.StorageProperties; - -namespace SecureFolderFS.Core.FileSystem.Storage.StorageProperties -{ - /// - public class CryptoFileProperties : ISizeProperties, IDateProperties, IBasicProperties - { - private readonly FileSystemSpecifics _specifics; - private readonly IBasicProperties _properties; - - public CryptoFileProperties(FileSystemSpecifics specifics, IBasicProperties properties) - { - _specifics = specifics; - _properties = properties; - } - - /// - public async Task?> GetSizeAsync(CancellationToken cancellationToken = default) - { - if (_properties is not ISizeProperties sizeProperties) - return null; - - var sizeProperty = await sizeProperties.GetSizeAsync(cancellationToken); - if (sizeProperty is null) - return null; - - var plaintextSize = _specifics.Security.ContentCrypt.CalculatePlaintextSize(sizeProperty.Value); - return new GenericProperty(plaintextSize); - } - - /// - public async Task> GetDateCreatedAsync(CancellationToken cancellationToken = default) - { - if (_properties is not IDateProperties dateProperties) - throw new NotSupportedException($"{nameof(IDateProperties)} is not supported."); - - return await dateProperties.GetDateCreatedAsync(cancellationToken); - } - - /// - public async Task> GetDateModifiedAsync(CancellationToken cancellationToken = default) - { - if (_properties is not IDateProperties dateProperties) - throw new NotSupportedException($"{nameof(IDateProperties)} is not supported."); - - return await dateProperties.GetDateModifiedAsync(cancellationToken); - } - - /// - public async IAsyncEnumerable> GetPropertiesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) - { - if (_properties is ISizeProperties) - yield return await GetSizeAsync(cancellationToken) as IStorageProperty; - - if (_properties is IDateProperties) - { - yield return await GetDateCreatedAsync(cancellationToken) as IStorageProperty; - yield return await GetDateModifiedAsync(cancellationToken) as IStorageProperty; - } - } - } -} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoFolderProperties.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoFolderProperties.cs deleted file mode 100644 index 871ccaf52..000000000 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoFolderProperties.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using OwlCore.Storage; -using SecureFolderFS.Storage.StorageProperties; - -namespace SecureFolderFS.Core.FileSystem.Storage.StorageProperties -{ - /// - public class CryptoFolderProperties : IDateProperties, IBasicProperties - { - private readonly IBasicProperties _properties; - - public CryptoFolderProperties(IBasicProperties properties) - { - _properties = properties; - } - - /// - public async Task> GetDateCreatedAsync(CancellationToken cancellationToken = default) - { - if (_properties is not IDateProperties dateProperties) - throw new NotSupportedException($"{nameof(IDateProperties)} is not supported."); - - return await dateProperties.GetDateCreatedAsync(cancellationToken); - } - - /// - public async Task> GetDateModifiedAsync(CancellationToken cancellationToken = default) - { - if (_properties is not IDateProperties dateProperties) - throw new NotSupportedException($"{nameof(IDateProperties)} is not supported."); - - return await dateProperties.GetDateModifiedAsync(cancellationToken); - } - - /// - public async IAsyncEnumerable> GetPropertiesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) - { - if (_properties is IDateProperties) - { - yield return await GetDateCreatedAsync(cancellationToken) as IStorageProperty; - yield return await GetDateModifiedAsync(cancellationToken) as IStorageProperty; - } - } - } -} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoLastModifiedAtProperty.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoLastModifiedAtProperty.cs new file mode 100644 index 000000000..178aa9438 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoLastModifiedAtProperty.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; + +namespace SecureFolderFS.Core.FileSystem.Storage.StorageProperties +{ + /// + public sealed class CryptoLastModifiedAtProperty : ILastModifiedAtProperty + { + private readonly ILastModifiedAtProperty? _lastModifiedAtProperty; + + /// + public string Id { get; } + + /// + public string Name { get; } + + public CryptoLastModifiedAtProperty(string id, ILastModifiedAtProperty? lastModifiedAtProperty) + { + _lastModifiedAtProperty = lastModifiedAtProperty; + Name = nameof(ILastModifiedAt.LastModifiedAt); + Id = $"{id}/{nameof(ILastModifiedAt.LastModifiedAt)}"; + } + + /// + public async Task GetValueAsync(CancellationToken cancellationToken = default) + { + if (_lastModifiedAtProperty is null) + return null; + + return await _lastModifiedAtProperty.GetValueAsync(cancellationToken); + } + } +} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs new file mode 100644 index 000000000..76c34f7e9 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs @@ -0,0 +1,40 @@ +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Storage.StorageProperties; + +namespace SecureFolderFS.Core.FileSystem.Storage.StorageProperties +{ + /// + public sealed class CryptoSizeOfProperty : ISizeOfProperty + { + private readonly ISizeOfProperty? _sizeOfProperty; + private readonly FileSystemSpecifics _specifics; + + /// + public string Id { get; } + + /// + public string Name { get; } + + public CryptoSizeOfProperty(string id, FileSystemSpecifics specifics, ISizeOfProperty? sizeOfProperty) + { + _specifics = specifics; + _sizeOfProperty = sizeOfProperty; + Name = nameof(ISizeOf.SizeOf); + Id = $"{id}/{nameof(ISizeOf.SizeOf)}"; + } + + /// + public async Task GetValueAsync(CancellationToken cancellationToken = default) + { + if (_sizeOfProperty is null) + return null; + + var ciphertextSize = await _sizeOfProperty.GetValueAsync(cancellationToken); + if (ciphertextSize is null) + return null; + + return _specifics.Security.ContentCrypt.CalculatePlaintextSize(ciphertextSize.Value - _specifics.Security.HeaderCrypt.HeaderCiphertextSize); + } + } +} \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs index 23a94bbcb..65ea8bf2d 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs @@ -86,43 +86,47 @@ public override int Read(Span buffer) if (buffer.IsEmpty) return 0; - if (Inner.IsEndOfStream()) - return FileSystem.Constants.FILE_EOF; + // For seekable streams, perform EOF checks up front + if (Inner.CanSeek) + { + if (Inner.IsEndOfStream()) + return Constants.FILE_EOF; - var ciphertextStreamLength = Inner.Length; - if (ciphertextStreamLength < _security.HeaderCrypt.HeaderCiphertextSize) - return FileSystem.Constants.FILE_EOF; // TODO: HealthAPI - report invalid header size + if (Inner.Length < _security.HeaderCrypt.HeaderCiphertextSize) + return Constants.FILE_EOF; - var lengthToEof = Length - Position; - if (lengthToEof <= 0L) - return FileSystem.Constants.FILE_EOF; + if (Length - Position <= 0L) + return Constants.FILE_EOF; + } // Read header if is not ready if (!_headerBuffer.ReadHeader(Inner, _security)) throw new CryptographicException("Could not read header."); - var read = 0; var positionInBuffer = 0; var plaintextChunkSize = _security.ContentCrypt.ChunkPlaintextSize; - var adjustedBuffer = buffer.Slice(0, (int)Math.Min(buffer.Length, lengthToEof)); + var adjustedBuffer = Inner.CanSeek + ? buffer.Slice(0, (int)Math.Min(buffer.Length, Length - Position)) + : buffer; while (positionInBuffer < adjustedBuffer.Length) { - var readPosition = Position + read; + var readPosition = Position + positionInBuffer; var chunkNumber = readPosition / plaintextChunkSize; var offsetInChunk = (int)(readPosition % plaintextChunkSize); - var length = Math.Min(adjustedBuffer.Length - positionInBuffer, plaintextChunkSize - offsetInChunk); var copied = _chunkAccess.CopyFromChunk(chunkNumber, adjustedBuffer.Slice(positionInBuffer), offsetInChunk); if (copied < 0) throw new CryptographicException(); + if (copied == 0) + break; + positionInBuffer += copied; - read += length; } - _position += read; - return read; + _position += positionInBuffer; + return positionInBuffer == 0 ? Constants.FILE_EOF : positionInBuffer; } /// @@ -132,23 +136,21 @@ public override void Write(ReadOnlySpan buffer) if (!CanWrite) throw FileSystemExceptions.StreamReadOnly; - // Don't initiate write if the buffer is empty + // Don't initiate writing if the buffer is empty if (buffer.IsEmpty) return; if (CanSeek && Position > Length) { - // TODO: Maybe throw an exception? - // Write gap var gapLength = Position - Length; - // Generate weak noise - var weakNoise = new byte[gapLength]; - Random.Shared.NextBytes(weakNoise); + // Generate cryptographically secure random bytes for gap filling + var secureNoise = new byte[gapLength]; + RandomNumberGenerator.Fill(secureNoise); - // Write contents of weak noise array - WriteInternal(weakNoise, Length); + // Write contents of a secure noise array + WriteInternal(secureNoise, Length); } else { diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs index ce2eb2bf0..956baf1f9 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs @@ -40,7 +40,7 @@ protected async Task ValidateNameResultAsync(IStorableChild storable, C try { await ValidateNameAsync(storable, cancellationToken).ConfigureAwait(false); - return Result.Success; + return Result.Success(storable is IFile ? StorableType.File : StorableType.Folder); } catch (Exception ex) { diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/FileContentValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/FileContentValidator.cs new file mode 100644 index 000000000..9fe74f764 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/FileContentValidator.cs @@ -0,0 +1,79 @@ +using OwlCore.Storage; +using SecureFolderFS.Core.FileSystem.Helpers.Health; +using SecureFolderFS.Core.FileSystem.Helpers.Paths; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; + +namespace SecureFolderFS.Core.FileSystem.Validators +{ + /// + /// Validates file contents including header and chunk integrity. + /// + public sealed class FileContentValidator : BaseFileSystemValidator + { + public FileContentValidator(FileSystemSpecifics specifics) + : base(specifics) + { + } + + /// + public override async Task ValidateAsync(IFile value, CancellationToken cancellationToken = default) + { + if (PathHelpers.IsCoreName(value.Name)) + return; + + var result = await HealthHelpers.ValidateFileContentsAsync(value, specifics.Security, cancellationToken); + + if (result.IsIrrecoverable) + throw new FileHeaderCorruptedException(value.Name); + + if (result.CorruptedChunks.Count > 0) + throw new FileChunksCorruptedException(value.Name, result.CorruptedChunks); + } + + /// + public override async Task ValidateResultAsync(IFile value, CancellationToken cancellationToken = default) + { + try + { + await ValidateAsync(value, cancellationToken).ConfigureAwait(false); + return Result.Success(StorableType.File); + } + catch (Exception ex) + { + return Result.Failure(value, ex); + } + } + } + + /// + /// Exception thrown when a file header is corrupted and the file is irrecoverable. + /// + public sealed class FileHeaderCorruptedException : CryptographicException + { + public FileHeaderCorruptedException(string fileName) + : base($"File header is corrupted and cannot be recovered: {fileName}") + { + } + } + + /// + /// Exception thrown when one or more file chunks are corrupted. + /// + public sealed class FileChunksCorruptedException : CryptographicException + { + public IReadOnlyList CorruptedChunks { get; } + + public FileChunksCorruptedException(string fileName, IReadOnlyList corruptedChunks) + : base($"File has {corruptedChunks.Count} corrupted chunk(s): {fileName}") + { + CorruptedChunks = corruptedChunks; + } + } +} + diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/FileValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/FileValidator.cs deleted file mode 100644 index 4b7b60a38..000000000 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/FileValidator.cs +++ /dev/null @@ -1,45 +0,0 @@ -using OwlCore.Storage; -using SecureFolderFS.Core.FileSystem.Helpers.Paths; -using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Shared.Models; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace SecureFolderFS.Core.FileSystem.Validators -{ - /// - public sealed class FileValidator : BaseFileSystemValidator - { - private IResult FileSuccess { get; } = Result.Success(StorableType.File); - - public FileValidator(FileSystemSpecifics specifics) - : base(specifics) - { - } - - /// - public override Task ValidateAsync(IFile value, CancellationToken cancellationToken = default) - { - if (PathHelpers.IsCoreName(value.Name)) - return Task.CompletedTask; - - // TODO: Implement file validation (invalid chunks, checksum mismatch, etc...?) - return Task.CompletedTask; - } - - /// - public override async Task ValidateResultAsync(IFile value, CancellationToken cancellationToken = default) - { - try - { - await ValidateAsync(value, cancellationToken).ConfigureAwait(false); - return FileSuccess; - } - catch (Exception ex) - { - return Result.Failure(value, ex); - } - } - } -} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs new file mode 100644 index 000000000..63fa5c6eb --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs @@ -0,0 +1,93 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; +using SecureFolderFS.Core.FileSystem.Helpers.Paths; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Core.FileSystem.Validators +{ + /// + internal sealed class StructureContentsValidator : BaseFileSystemValidator<(IFolder, IProgress?)> + { + private readonly IAsyncValidator _folderValidator; + private readonly IAsyncValidator _fileContentValidator; + + public StructureContentsValidator(FileSystemSpecifics specifics, IAsyncValidator fileContentValidator, IAsyncValidator folderValidator) + : base(specifics) + { + _folderValidator = folderValidator; + _fileContentValidator = fileContentValidator; + } + + /// + public override Task ValidateAsync((IFolder, IProgress?) value, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + /// + public override async Task ValidateResultAsync((IFolder, IProgress?) value, CancellationToken cancellationToken = default) + { + var scannedFolder = value.Item1; + var reporter = value.Item2; + + var folderResult = await _folderValidator.ValidateResultAsync(scannedFolder, cancellationToken).ConfigureAwait(false); + reporter?.Report(folderResult); + + await foreach (var item in scannedFolder.GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false)) + { + if (PathHelpers.IsCoreName(item.Name)) + continue; + + switch (item) + { + case IFile file: + { + var fileResult = await _fileContentValidator.ValidateResultAsync(file, cancellationToken).ConfigureAwait(false); + var nameResult = folderResult.Successful + ? await ValidateNameResultAsync(item, cancellationToken).ConfigureAwait(false) + : Result.Success(StorableType.File); // Assuming success on name check here when a parent is broken will skip the checks + + if (!fileResult.Successful && !nameResult.Successful) + { + // Both have failed, report aggregated failure + reporter?.Report(Result<(IResult, IResult)>.Failure((fileResult, nameResult))); + } + else if (!fileResult.Successful) + { + // Only a file has failed, report file failure + reporter?.Report(fileResult); + } + else if (!nameResult.Successful) + { + // Only a name has failed, report name failure + reporter?.Report(nameResult); + } + else + { + // Both have succeeded, report file result success + reporter?.Report(fileResult); + } + + break; + } + + case IFolder: + { + if (folderResult.Successful) + { + var nameResult = await ValidateNameResultAsync(item, cancellationToken).ConfigureAwait(false); + reporter?.Report(nameResult); + } + + break; + } + } + } + + return folderResult; + } + } +} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureValidator.cs index 5a0843c8d..4bce08c7b 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureValidator.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureValidator.cs @@ -11,13 +11,11 @@ namespace SecureFolderFS.Core.FileSystem.Validators /// internal sealed class StructureValidator : BaseFileSystemValidator<(IFolder, IProgress?)> { - private readonly IAsyncValidator _fileValidator; private readonly IAsyncValidator _folderValidator; - public StructureValidator(FileSystemSpecifics specifics, IAsyncValidator fileValidator, IAsyncValidator folderValidator) + public StructureValidator(FileSystemSpecifics specifics, IAsyncValidator folderValidator) : base(specifics) { - _fileValidator = fileValidator; _folderValidator = folderValidator; } @@ -36,22 +34,16 @@ public override async Task ValidateResultAsync((IFolder, IProgress - public abstract class VFSRoot : IVFSRoot, IWrapper + /// + public abstract class VfsRoot : IVfsRoot, IWrapper { protected readonly FileSystemSpecifics specifics; @@ -16,16 +16,20 @@ public abstract class VFSRoot : IVFSRoot, IWrapper /// public IFolder VirtualizedRoot { get; } + /// + public IFolder PlaintextRoot { get; } + /// public abstract string FileSystemName { get; } /// public VirtualFileSystemOptions Options { get; } - protected VFSRoot(IFolder storageRoot, FileSystemSpecifics specifics) + protected VfsRoot(IFolder virtualizedRoot, IFolder plaintextRoot, FileSystemSpecifics specifics) { this.specifics = specifics; - VirtualizedRoot = storageRoot; + VirtualizedRoot = virtualizedRoot; + PlaintextRoot = plaintextRoot; Options = specifics.Options; // Automatically add created root diff --git a/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV1_V2.cs b/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV1_V2.cs index e80db7315..f27d7b1c0 100644 --- a/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV1_V2.cs +++ b/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV1_V2.cs @@ -1,4 +1,10 @@ -using OwlCore.Storage; +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; using SecureFolderFS.Core.Cryptography.Cipher; using SecureFolderFS.Core.Cryptography.Helpers; using SecureFolderFS.Core.Cryptography.SecureStore; @@ -9,13 +15,6 @@ using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.Extensions; -using System; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Core.Migration.AppModels { @@ -24,7 +23,7 @@ internal sealed class MigratorV1_V2 : IVaultMigratorModel { private readonly IAsyncSerializer _streamSerializer; private V1VaultConfigurationDataModel? _v1ConfigDataModel; - private VaultKeystoreDataModel? _v1KeystoreDataModel; + private V3VaultKeystoreDataModel? _v1KeystoreDataModel; /// public IFolder VaultFolder { get; } @@ -36,7 +35,7 @@ public MigratorV1_V2(IFolder vaultFolder, IAsyncSerializer streamSeriali } /// - public async Task UnlockAsync(IKey credentials, CancellationToken cancellationToken = default) + public async Task UnlockAsync(IKeyBytes credentials, CancellationToken cancellationToken = default) { if (credentials is not IPassword password) throw new ArgumentException($"Argument {credentials} is not of type {typeof(IPassword)}."); @@ -48,15 +47,15 @@ public async Task UnlockAsync(IKey credentials, CancellationToken c await using var keystoreStream = await keystoreFile.OpenReadAsync(cancellationToken); _v1ConfigDataModel = await _streamSerializer.DeserializeAsync(configStream, cancellationToken); - _v1KeystoreDataModel = await _streamSerializer.DeserializeAsync(keystoreStream, cancellationToken); + _v1KeystoreDataModel = await _streamSerializer.DeserializeAsync(keystoreStream, cancellationToken); if (_v1KeystoreDataModel is null) - throw new FormatException($"{nameof(VaultKeystoreDataModel)} was not in the correct format."); + throw new FormatException($"{nameof(V3VaultKeystoreDataModel)} was not in the correct format."); var kek = new byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; - using var dekKey = new SecureKey(Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH); - using var macKey = new SecureKey(Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH); + using var dekKey = new ManagedKey(Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH); + using var macKey = new ManagedKey(Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH); - Argon2id.Old_DeriveKey(password.ToArray(), _v1KeystoreDataModel.Salt, kek); + Argon2id.V2_DeriveKey(password.Key, _v1KeystoreDataModel.Salt, kek); // Unwrap keys using var rfc3394 = new Rfc3394KeyWrap(); @@ -88,7 +87,6 @@ public async Task MigrateAsync(IDisposable unlockContract, ProgressModel + { + // Initialize HMAC + using var hmacSha256 = new HMACSHA256(macKey.ToArray()); // Note: HMACSHA256 requires a byte[] key. + + // Update HMAC + hmacSha256.AppendData(BitConverter.GetBytes(v2ConfigDataModel.Version)); + hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.ContentCipherId(v2ConfigDataModel.ContentCipherId))); + hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.FileNameCipherId(v2ConfigDataModel.FileNameCipherId))); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(v2ConfigDataModel.Uid)); + hmacSha256.AppendFinalData(Encoding.UTF8.GetBytes(v2ConfigDataModel.AuthenticationMethod)); + + // Fill the hash to payload + hmacSha256.GetCurrentHash(v2ConfigDataModel.PayloadMac); + }); var configFile = await VaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken); await using var configStream = await configFile.OpenReadWriteAsync(cancellationToken); diff --git a/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV2_V3.cs b/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV2_V3.cs index 052ec98d9..74154a3fa 100644 --- a/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV2_V3.cs +++ b/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV2_V3.cs @@ -12,6 +12,7 @@ using System; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -20,12 +21,12 @@ namespace SecureFolderFS.Core.Migration.AppModels { /// - internal sealed class MigratorV2_V3 : IVaultMigratorModel, IProgress + internal sealed class MigratorV2_V3 : IVaultMigratorModel, IProgress { private readonly IAsyncSerializer _streamSerializer; private V2VaultConfigurationDataModel? _v2ConfigDataModel; - private VaultKeystoreDataModel? _v2KeystoreDataModel; - private SecretKey? _secretKeySequence; + private V3VaultKeystoreDataModel? _v2KeystoreDataModel; + private ManagedKey? _secretKeySequence; private bool _wasNewPasswordSet; /// @@ -38,14 +39,17 @@ public MigratorV2_V3(IFolder vaultFolder, IAsyncSerializer streamSeriali } /// - public void Report(IKey key) + public void Report(IKeyBytes key) { _wasNewPasswordSet = true; - _secretKeySequence = SecureKey.TakeOwnership(key.ToArray()); + + var copy = new byte[key.Length]; + key.Key.AsSpan().CopyTo(copy); + _secretKeySequence = ManagedKey.TakeOwnership(copy); } /// - public async Task UnlockAsync(IKey credentials, CancellationToken cancellationToken = default) + public async Task UnlockAsync(IKeyBytes credentials, CancellationToken cancellationToken = default) { if (credentials is not KeySequence keySequence) throw new ArgumentException($"Argument {credentials} is not of type {typeof(KeySequence)}."); @@ -53,7 +57,7 @@ public async Task UnlockAsync(IKey credentials, CancellationToken c _secretKeySequence?.Dispose(); _secretKeySequence = null; - var secretKeySequence = SecureKey.TakeOwnership(keySequence.ToArray()); + var secretKeySequence = ManagedKey.TakeOwnership(keySequence.Key); var configFile = await VaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken); var keystoreFile = await VaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_KEYSTORE_FILENAME, cancellationToken); @@ -61,15 +65,15 @@ public async Task UnlockAsync(IKey credentials, CancellationToken c await using var keystoreStream = await keystoreFile.OpenReadAsync(cancellationToken); _v2ConfigDataModel = await _streamSerializer.DeserializeAsync(configStream, cancellationToken); - _v2KeystoreDataModel = await _streamSerializer.DeserializeAsync(keystoreStream, cancellationToken); + _v2KeystoreDataModel = await _streamSerializer.DeserializeAsync(keystoreStream, cancellationToken); if (_v2KeystoreDataModel is null) - throw new FormatException($"{nameof(VaultKeystoreDataModel)} was not in the correct format."); + throw new FormatException($"{nameof(V3VaultKeystoreDataModel)} was not in the correct format."); var kek = new byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; - using var dekKey = new SecureKey(Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH); - using var macKey = new SecureKey(Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH); + using var dekKey = new ManagedKey(Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH); + using var macKey = new ManagedKey(Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH); - Argon2id.Old_DeriveKey(secretKeySequence, _v2KeystoreDataModel.Salt, kek); + Argon2id.V2_DeriveKey(secretKeySequence, _v2KeystoreDataModel.Salt, kek); // Unwrap keys using var rfc3394 = new Rfc3394KeyWrap(); @@ -96,26 +100,33 @@ public async Task RecoverAsync(string encodedRecoveryKey, Cancellat await using var keystoreStream = await keystoreFile.OpenReadAsync(cancellationToken); _v2ConfigDataModel = await _streamSerializer.DeserializeAsync(configStream, cancellationToken); - _v2KeystoreDataModel = await _streamSerializer.DeserializeAsync(keystoreStream, cancellationToken); + _v2KeystoreDataModel = await _streamSerializer.DeserializeAsync(keystoreStream, cancellationToken); if (_v2ConfigDataModel is null) throw new FormatException($"{nameof(V2VaultConfigurationDataModel)} was not in the correct format."); // Initialize HMAC - using var hmacSha256 = new HMACSHA256(keyPair.MacKey.Key); - hmacSha256.AppendData(BitConverter.GetBytes(_v2ConfigDataModel.Version)); - hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.ContentCipherId(_v2ConfigDataModel.ContentCipherId))); - hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.FileNameCipherId(_v2ConfigDataModel.FileNameCipherId))); - hmacSha256.AppendData(Encoding.UTF8.GetBytes(_v2ConfigDataModel.Uid)); - hmacSha256.AppendFinalData(Encoding.UTF8.GetBytes(_v2ConfigDataModel.AuthenticationMethod)); + var payloadMac = keyPair.MacKey.UseKey(macKey => + { + using var hmacSha256 = new HMACSHA256(macKey.ToArray()); // Note: HMACSHA256 requires a byte[] key. + hmacSha256.AppendData(BitConverter.GetBytes(_v2ConfigDataModel.Version)); + hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.ContentCipherId(_v2ConfigDataModel.ContentCipherId))); + hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.FileNameCipherId(_v2ConfigDataModel.FileNameCipherId))); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(_v2ConfigDataModel.Uid)); + hmacSha256.AppendFinalData(Encoding.UTF8.GetBytes(_v2ConfigDataModel.AuthenticationMethod)); - var payloadMac = new byte[HMACSHA256.HashSizeInBytes]; - hmacSha256.GetCurrentHash(payloadMac); + var payloadMac = new byte[HMACSHA256.HashSizeInBytes]; + hmacSha256.GetCurrentHash(payloadMac); + + return payloadMac; + }); // Check if stored hash equals to computed hash - if (!payloadMac.SequenceEqual(_v2ConfigDataModel.PayloadMac ?? [])) + if (!CryptographicOperations.FixedTimeEquals(payloadMac, _v2ConfigDataModel.PayloadMac ?? [])) throw new CryptographicException("Vault hash doesn't match the computed hash."); - return KeyPair.ImportKeys(keyPair.DekKey, keyPair.MacKey); + // Create copies of keys and dispose of the original instance + using (keyPair) + return keyPair.CreateCopy(); } /// @@ -133,8 +144,6 @@ public async Task MigrateAsync(IDisposable unlockContract, ProgressModel + { + // Initialize HMAC + using var hmacSha256 = new HMACSHA256(mac.ToArray()); // Note: HMACSHA256 requires a byte[] key. + + // Update HMAC + hmacSha256.AppendData(BitConverter.GetBytes(v3ConfigDataModel.Version)); + hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.ContentCipherId(v3ConfigDataModel.ContentCipherId))); + hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.FileNameCipherId(v3ConfigDataModel.FileNameCipherId))); + hmacSha256.AppendData(BitConverter.GetBytes(v3ConfigDataModel.RecycleBinSize)); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(v3ConfigDataModel.FileNameEncodingId)); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(v3ConfigDataModel.Uid)); + hmacSha256.AppendFinalData(Encoding.UTF8.GetBytes(v3ConfigDataModel.AuthenticationMethod)); + + // Fill the hash to payload + hmacSha256.GetCurrentHash(v3ConfigDataModel.PayloadMac); + + // Vault Keystore ------------------------------------ + // + Span kek = stackalloc byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; + Argon2id.DeriveKey(_secretKeySequence, _v2KeystoreDataModel.Salt, kek); + + using var rfc3394 = new Rfc3394KeyWrap(); + var newWrappedDekKey = rfc3394.WrapKey(dek, kek); + var newWrappedMacKey = rfc3394.WrapKey(mac, kek); + + return (newWrappedDekKey, newWrappedMacKey); + }); + + var v3KeystoreDataModel = new V3VaultKeystoreDataModel() { Salt = _v2KeystoreDataModel.Salt, - WrappedDekKey = newWrappedDekKek, - WrappedMacKey = newWrappedMacKek + WrappedDekKey = newWrappedKeys.newWrappedDekKey, + WrappedMacKey = newWrappedKeys.newWrappedMacKey }; var configFile = await VaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken); diff --git a/src/Core/SecureFolderFS.Core.Migration/SecureFolderFS.Core.Migration.csproj b/src/Core/SecureFolderFS.Core.Migration/SecureFolderFS.Core.Migration.csproj index c38425b02..f06d234a5 100644 --- a/src/Core/SecureFolderFS.Core.Migration/SecureFolderFS.Core.Migration.csproj +++ b/src/Core/SecureFolderFS.Core.Migration/SecureFolderFS.Core.Migration.csproj @@ -4,6 +4,7 @@ net10.0 enable latest + true diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AndroidFileSystem.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AndroidFileSystem.cs index 57b79b986..c87a7f5b7 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AndroidFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AndroidFileSystem.cs @@ -4,16 +4,14 @@ using SecureFolderFS.Core.FileSystem.AppModels; using SecureFolderFS.Core.FileSystem.Extensions; using SecureFolderFS.Core.FileSystem.Storage; -using SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Storage.Enums; using SecureFolderFS.Storage.VirtualFileSystem; -using IFileSystem = SecureFolderFS.Storage.VirtualFileSystem.IFileSystem; namespace SecureFolderFS.Core.MobileFS.Platforms.Android { /// - public sealed class AndroidFileSystem : IFileSystem + public sealed class AndroidFileSystem : IFileSystemInfo { /// public string Id { get; } = Constants.Android.FileSystem.FS_ID; @@ -28,7 +26,7 @@ public Task GetStatusAsync(CancellationToken cancellatio } /// - public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) + public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) { await Task.CompletedTask; if (unlockContract is not IWrapper wrapper) @@ -39,7 +37,7 @@ public async Task MountAsync(IFolder folder, IDisposable unlockContrac fileSystemOptions.SetupValidators(specifics); var storageRoot = new CryptoFolder(Path.DirectorySeparatorChar.ToString(), specifics.ContentFolder, specifics); - return new AndroidVFSRoot(storageRoot, specifics); + return new AndroidVfsRoot(storageRoot, specifics); } } } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AndroidVfsRoot.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AndroidVfsRoot.cs new file mode 100644 index 000000000..2d7e9e32e --- /dev/null +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AndroidVfsRoot.cs @@ -0,0 +1,31 @@ +using OwlCore.Storage; +using SecureFolderFS.Core.FileSystem; +using SecureFolderFS.Storage.VirtualFileSystem; + +namespace SecureFolderFS.Core.MobileFS.Platforms.Android +{ + /// + internal sealed class AndroidVfsRoot : VfsRoot + { + private bool _disposed; + + /// + public override string FileSystemName { get; } = Constants.Android.FileSystem.FS_NAME; + + public AndroidVfsRoot(IFolder storageRoot, FileSystemSpecifics specifics) + : base(storageRoot, storageRoot, specifics) + { + } + + /// + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + FileSystemManager.Instance.FileSystems.Remove(this); + await base.DisposeAsync(); + } + } +} diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/AndroidVFSRoot.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/AndroidVFSRoot.cs deleted file mode 100644 index 1cd5ffaea..000000000 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/AndroidVFSRoot.cs +++ /dev/null @@ -1,32 +0,0 @@ -using OwlCore.Storage; -using SecureFolderFS.Core.FileSystem; -using SecureFolderFS.Storage.VirtualFileSystem; - -namespace SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem -{ - /// - internal sealed class AndroidVFSRoot : VFSRoot - { - private bool _disposed; - - /// - public override string FileSystemName { get; } = Constants.Android.FileSystem.FS_NAME; - - public AndroidVFSRoot(IFolder storageRoot, FileSystemSpecifics specifics) - : base(storageRoot, specifics) - { - } - - /// - public override ValueTask DisposeAsync() - { - if (!_disposed) - { - _disposed = true; - FileSystemManager.Instance.FileSystems.Remove(this); - } - - return ValueTask.CompletedTask; - } - } -} diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs index 1371c910e..f3c27dd50 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs @@ -2,9 +2,9 @@ using Android.Provider; using Android.Webkit; using OwlCore.Storage; -using SecureFolderFS.Core.MobileFS.Platforms.Android.Helpers; +using SecureFolderFS.Shared.Enums; +using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.Extensions; -using SecureFolderFS.Storage.StorageProperties; using static Android.Provider.DocumentsContract; using IOPath = System.IO.Path; @@ -18,12 +18,12 @@ private bool AddRoot(MatrixCursor matrix, SafRoot safRoot, int iconRid) if (row is null) return false; - var rootFolderId = GetDocumentIdForStorable(safRoot.StorageRoot.VirtualizedRoot, safRoot.RootId); - row.Add(DocumentsContract.Root.ColumnRootId, safRoot.RootId); - row.Add(DocumentsContract.Root.ColumnDocumentId, rootFolderId); - row.Add(DocumentsContract.Root.ColumnTitle, safRoot.StorageRoot.Options.VolumeName); - row.Add(DocumentsContract.Root.ColumnIcon, iconRid); - row.Add(DocumentsContract.Root.ColumnFlags, (int)(DocumentRootFlags.LocalOnly | DocumentRootFlags.SupportsCreate)); + var rootFolderId = GetDocumentIdForStorable(safRoot.StorageRoot.PlaintextRoot, safRoot.RootId); + row.Add(Root.ColumnRootId, safRoot.RootId); + row.Add(Root.ColumnDocumentId, rootFolderId); + row.Add(Root.ColumnTitle, safRoot.StorageRoot.Options.VolumeName); + row.Add(Root.ColumnIcon, iconRid); + row.Add(Root.ColumnFlags, (int)(DocumentRootFlags.LocalOnly | DocumentRootFlags.SupportsCreate)); return true; } @@ -75,17 +75,18 @@ void AddFlags() if (!safRoot.StorageRoot.Options.IsReadOnly) baseFlags |= DocumentContractFlags.SupportsWrite; - baseFlags |= DocumentContractFlags.SupportsThumbnail; - row.Add(Document.ColumnFlags, (int)baseFlags); + var typeHint = FileTypeHelper.GetTypeHint(storable); + if (typeHint is TypeHint.Image or TypeHint.Media) + baseFlags |= DocumentContractFlags.SupportsThumbnail; } else { baseFlags |= DocumentContractFlags.DirPrefersGrid; if (!safRoot.StorageRoot.Options.IsReadOnly) baseFlags |= DocumentContractFlags.DirSupportsCreate; - - row.Add(Document.ColumnFlags, (int)baseFlags); } + + row.Add(Document.ColumnFlags, (int)baseFlags); } void AddMimeType() => row.Add(Document.ColumnMimeType, GetMimeForStorable(storable)); void AddDocumentId() => row.Add(Document.ColumnDocumentId, documentId); @@ -110,7 +111,7 @@ void AddDisplayName() if (safRoot is null) return null; - if (storable.Id == safRoot.StorageRoot.VirtualizedRoot.Id) + if (storable.Id == safRoot.StorageRoot.PlaintextRoot.Id) return $"{safRoot.RootId}:"; return $"{safRoot.RootId}:{storable.Id}"; @@ -137,14 +138,14 @@ void AddDisplayName() if (safRoot is null) return null; - // Return base folder if the path is empty + // Return the base folder if the path is empty if (string.IsNullOrEmpty(path)) - return safRoot.StorageRoot.VirtualizedRoot; + return safRoot.StorageRoot.PlaintextRoot; - return safRoot.StorageRoot.VirtualizedRoot.GetItemByRelativePathAsync(path).ConfigureAwait(false).GetAwaiter().GetResult(); + return safRoot.StorageRoot.PlaintextRoot.GetItemByRelativePathAsync(path).ConfigureAwait(false).GetAwaiter().GetResult(); } - private string GetMimeForStorable(IStorable storable) + private static string GetMimeForStorable(IStorable storable) { if (storable is IFolder) return Document.MimeTypeDir; @@ -154,7 +155,7 @@ private string GetMimeForStorable(IStorable storable) if (string.IsNullOrEmpty(extension)) return "application/octet-stream"; - // Remove the starting . (dot) + // Remove the starting dot return MimeTypeMap.Singleton?.GetMimeTypeFromExtension(extension.Substring(1)) ?? string.Empty; } } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs index e781bcc6d..465f39aa6 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs @@ -4,18 +4,19 @@ using Android.Database; using Android.OS; using Android.Provider; -using Android.Runtime; using Android.Util; -using Java.IO; +using Microsoft.Maui.Platform; using OwlCore.Storage; using SecureFolderFS.Core.MobileFS.Platforms.Android.Helpers; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Enums; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.Renamable; using static SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem.Projections; using Point = Android.Graphics.Point; +using Uri = Android.Net.Uri; namespace SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem { @@ -47,9 +48,13 @@ public override bool OnCreate() public override ICursor? QueryRoots(string[]? projection) { var matrix = new MatrixCursor(projection ?? DefaultRootProjection); + var rid = MauiApplication.Current.GetDrawableId("app_icon.png"); + if (rid == 0) + rid = Constants.Android.Saf.IC_LOCK_LOCK; + foreach (var item in _rootCollection?.Roots ?? Enumerable.Empty()) { - AddRoot(matrix, item, Constants.Android.Saf.IC_LOCK_LOCK); + AddRoot(matrix, item, rid); } return matrix; @@ -75,9 +80,8 @@ public override bool OnCreate() var createdItem = (IStorableChild)(mimeType switch { - DocumentsContract.Document.MimeTypeDir => parentFolder.CreateFolderAsync(displayName, false) - .ConfigureAwait(false).GetAwaiter().GetResult(), - _ => parentFolder.CreateFileAsync(displayName, false).ConfigureAwait(false).GetAwaiter().GetResult() + DocumentsContract.Document.MimeTypeDir => parentFolder.CreateFolderAsync(displayName).ConfigureAwait(false).GetAwaiter().GetResult(), + _ => parentFolder.CreateFileAsync(displayName).ConfigureAwait(false).GetAwaiter().GetResult() }); var rootId = parentDocumentId.Split(':', 2)[0]; @@ -111,14 +115,12 @@ public override bool OnCreate() if (safRoot.StorageRoot.Options.IsReadOnly && parcelFileMode is ParcelFileMode.WriteOnly or ParcelFileMode.ReadWrite) return null; - return _storageManager.OpenProxyFileDescriptor(parcelFileMode, new ReadWriteCallbacks(stream), new Handler(Looper.MainLooper)); - - // var storageManager = (StorageManager?)this.Context?.GetSystemService(Context.StorageService); - // if (storageManager is null) - // return null; - // - // var parcelFileMode = ToParcelFileMode(mode); - // return storageManager.OpenProxyFileDescriptor(parcelFileMode, new ReadWriteCallbacks(stream), new Handler(Looper.MainLooper!)); + var handlerThread = new HandlerThread("ProxyFD-" + documentId); + handlerThread.Start(); + return _storageManager.OpenProxyFileDescriptor( + parcelFileMode, + new ReadWriteCallbacks(stream, handlerThread), + new Handler(handlerThread.Looper!)); static ParcelFileMode ToParcelFileMode(string? fileMode) { @@ -171,7 +173,7 @@ static FileAccess ToFileAccess(string? fileMode) if (parent is not IFolder folder) return matrix; - var items = folder.GetItemsAsync().ToArrayAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + var items = folder.GetItemsAsync().ToArrayAsyncImpl().ConfigureAwait(false).GetAwaiter().GetResult(); foreach (var item in items) { AddDocumentAsync(matrix, item, null).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -248,7 +250,7 @@ public override void DeleteDocument(string? documentId) return Path.Combine(targetParentDocumentId, movedFile.Name); } - case IChildFolder folder: + case IModifiableFolder folder: { var movedFolder = destinationFolder.MoveFromAsync(folder, sourceParentFolder, false, CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult(); return Path.Combine(targetParentDocumentId, movedFolder.Name); @@ -284,13 +286,13 @@ public override void DeleteDocument(string? documentId) return null; var renamedItem = renamableFolder.RenameAsync(storableChild, displayName).ConfigureAwait(false).GetAwaiter().GetResult(); - if (renamedItem is IWrapper { Inner: IWrapper fileUriWrapper }) + if (renamedItem is IWrapper { Inner: IWrapper fileUriWrapper }) return fileUriWrapper.Inner.ToString(); - if (renamedItem is IWrapper { Inner: IWrapper folderUriWrapper }) + if (renamedItem is IWrapper { Inner: IWrapper folderUriWrapper }) return folderUriWrapper.Inner.ToString(); - throw new InvalidOperationException($"{nameof(renamedItem)} does not implement {nameof(IWrapper)}."); + throw new InvalidOperationException($"{nameof(renamedItem)} does not implement {nameof(IWrapper)}."); } /// @@ -348,10 +350,14 @@ public override void DeleteDocument(string? documentId) try { + // Honor sizeHint from the caller instead of always using a fixed size + var size = sizeHint is not null + ? (uint)Math.Max(sizeHint.X, sizeHint.Y) + : 300U; + using var inputStream = file.OpenReadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - using var thumbnailStream = ThumbnailHelpers.GenerateImageThumbnailAsync(inputStream, 300U).ConfigureAwait(false).GetAwaiter().GetResult(); + var thumbnailStream = ThumbnailHelpers.GenerateImageThumbnailAsync(inputStream, size).ConfigureAwait(false).GetAwaiter().GetResult(); - // Need to copy thumbnail stream to a pipe (ParcelFileDescriptor with input/output stream) var twoWayPipe = ParcelFileDescriptor.CreatePipe(); if (twoWayPipe is null) return null; @@ -361,17 +367,25 @@ public override void DeleteDocument(string? documentId) { try { - int bytesRead; var buffer = new byte[8192]; - while ((bytesRead = thumbnailStream.Read(buffer, 0, buffer.Length)) > 0) - output.Write(buffer, 0, bytesRead); + using (thumbnailStream) + { + int read; + while ((read = thumbnailStream.Read(buffer, 0, buffer.Length)) > 0) + output.Write(buffer, 0, read); + } output.Flush(); - output.Close(); } - catch + catch (Exception ex) { - // Handle if needed + Log.Error(nameof(FileSystemProvider), $"Failed to write thumbnail to pipe. {ex}"); + } + finally + { + // Always close the write end so the read end gets a clean EOF, + // even if the drain throws midway + output.Close(); } }); diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs index a720dcd58..d54b63028 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs @@ -1,15 +1,16 @@ using Android.OS; -using Android.Systems; namespace SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem { internal sealed class ReadWriteCallbacks : StorageManagerCompat.ProxyFileDescriptorCallbackCompat { private readonly Stream _stream; + private readonly HandlerThread? _handlerThread; - public ReadWriteCallbacks(Stream stream) + public ReadWriteCallbacks(Stream stream, HandlerThread? handlerThread = null) { _stream = stream; + _handlerThread = handlerThread; } /// @@ -27,8 +28,7 @@ public override int OnRead(long offset, int size, byte[]? data) return 0; // Seek to the requested offset - if (offset > 0) - _stream.Seek(offset, SeekOrigin.Begin); + _stream.Seek(offset, SeekOrigin.Begin); // Read the requested data return _stream.Read(data.AsSpan(0, size)); @@ -50,8 +50,7 @@ public override int OnWrite(long offset, int size, byte[]? data) return 0; // Seek to the requested offset - if (offset > 0) - _stream.Seek(offset, SeekOrigin.Begin); + _stream.Seek(offset, SeekOrigin.Begin); // Write the requested data _stream.Write(data.AsSpan(0, size)); @@ -86,6 +85,7 @@ public override void OnFsync() public override void OnRelease() { _stream.Dispose(); + _handlerThread?.QuitSafely(); } } } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs index 77ab62ffe..492d6e7f9 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs @@ -7,8 +7,10 @@ namespace SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem { - internal sealed class RootCollection : IDisposable + public sealed class RootCollection : IDisposable { + public static RootCollection? Instance { get; private set; } + private readonly Context _context; public List Roots { get; } @@ -17,6 +19,7 @@ public RootCollection(Context context) { _context = context; Roots = new(); + Instance = this; FileSystemManager.Instance.FileSystems.CollectionChanged += FileSystemManager_CollectionChanged; } @@ -30,7 +33,7 @@ public RootCollection(Context context) { foreach (var safRoot in Roots) { - if (storable.Id.StartsWith(safRoot.StorageRoot.VirtualizedRoot.Id)) + if (storable.Id.StartsWith(safRoot.StorageRoot.PlaintextRoot.Id)) return safRoot; } @@ -43,7 +46,7 @@ private void FileSystemManager_CollectionChanged(object? sender, NotifyCollectio { case NotifyCollectionChangedAction.Add: { - if (e.NewItems?[0] is not IVFSRoot storageRoot) + if (e.NewItems?[0] is not IVfsRoot storageRoot) return; // Add to available roots @@ -55,7 +58,7 @@ private void FileSystemManager_CollectionChanged(object? sender, NotifyCollectio case NotifyCollectionChangedAction.Remove: { - if (e.OldItems?[0] is not IVFSRoot storageRoot) + if (e.OldItems?[0] is not IVfsRoot storageRoot) return; // Remove from available roots diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/SafRoot.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/SafRoot.cs index a9705b9c4..87cb5ede6 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/SafRoot.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/SafRoot.cs @@ -2,5 +2,5 @@ namespace SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem { - internal sealed record SafRoot(IVFSRoot StorageRoot, string RootId); + public sealed record SafRoot(IVfsRoot StorageRoot, string RootId); } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs index 5634634c9..be5870d16 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs @@ -1,6 +1,6 @@ using Android.Graphics; using Android.Media; -using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.Streams; using ExifInterface = AndroidX.ExifInterface.Media.ExifInterface; using Stream = System.IO.Stream; @@ -12,24 +12,37 @@ public static async Task GenerateImageThumbnailAsync(Stream stream, uint { // Read EXIF var exif = new ExifInterface(stream); - stream.Position = 0; + stream.Position = 0L; - // Get bounds - var boundsOptions = new BitmapFactory.Options { InJustDecodeBounds = true }; - await BitmapFactory.DecodeStreamAsync(stream, null, boundsOptions).ConfigureAwait(false); - stream.Position = 0; + // Attempt to get dimensions from EXIF tags to avoid a full bounds decode pass + var exifWidth = exif.GetAttributeInt(ExifInterface.TagImageWidth, 0); + var exifHeight = exif.GetAttributeInt(ExifInterface.TagImageLength, 0); - var (width, height) = (boundsOptions.OutWidth, boundsOptions.OutHeight); - var scale = Math.Min((float)maxSize / width, (float)maxSize / height); - var inSampleSize = CalculateInSampleSize(width, height, (int)(width * scale), (int)(height * scale)); + int width, height; + if (exifWidth > 0 && exifHeight > 0) + { + width = exifWidth; + height = exifHeight; + } + else + { + // Fall back to bounds decode if EXIF dimensions are missing + var boundsOptions = new BitmapFactory.Options { InJustDecodeBounds = true }; + await BitmapFactory.DecodeStreamAsync(stream, null, boundsOptions).ConfigureAwait(false); + stream.Position = 0L; + + width = boundsOptions.OutWidth; + height = boundsOptions.OutHeight; + } - var options = new BitmapFactory.Options + var inSampleSize = CalculateInSampleSize(width, height, (int)maxSize); + var options = new BitmapFactory.Options() { InJustDecodeBounds = false, InSampleSize = inSampleSize }; - using var bitmap = await BitmapFactory.DecodeStreamAsync(stream, null, options); + using var bitmap = await BitmapFactory.DecodeStreamAsync(stream, null, options).ConfigureAwait(false); if (bitmap is null) throw new Exception("Failed to decode image."); @@ -37,16 +50,10 @@ public static async Task GenerateImageThumbnailAsync(Stream stream, uint return await CompressBitmapAsync(rotated).ConfigureAwait(false); } - private static int CalculateInSampleSize(int width, int height, int reqWidth, int reqHeight) + private static int CalculateInSampleSize(int width, int height, int reqSize) { var inSampleSize = 1; - if (height <= reqHeight && width <= reqWidth) - return inSampleSize; - - var halfHeight = height / 2; - var halfWidth = width / 2; - - while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) + while ((width / (inSampleSize * 2)) >= reqSize && (height / (inSampleSize * 2)) >= reqSize) inSampleSize *= 2; return inSampleSize; @@ -97,19 +104,20 @@ private static Bitmap ApplyExifOrientation(Bitmap bitmap, ExifInterface exif) } var rotated = Bitmap.CreateBitmap(bitmap, 0, 0, bitmap.Width, bitmap.Height, matrix, true); - bitmap.Recycle(); bitmap.Dispose(); return rotated; } public static async Task CompressBitmapAsync(Bitmap bitmap) { - const int IMAGE_THUMBNAIL_QUALITY = 80; - var ms = new OnDemandDisposableStream(); - await bitmap.CompressAsync(Bitmap.CompressFormat.Jpeg, IMAGE_THUMBNAIL_QUALITY, ms).ConfigureAwait(false); - ms.Position = 0; - bitmap.Recycle(); - return ms; + const int IMAGE_THUMBNAIL_QUALITY = 70; + + var memoryStream = new MemoryStream(); + await bitmap.CompressAsync(Bitmap.CompressFormat.Jpeg!, IMAGE_THUMBNAIL_QUALITY, memoryStream).ConfigureAwait(false); + memoryStream.Position = 0L; + bitmap.Dispose(); + + return new NonDisposableStream(memoryStream); } } -} +} \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/StorageManagerCompat.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/StorageManagerCompat.cs index 152eea21a..db4c9a6a6 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/StorageManagerCompat.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/StorageManagerCompat.cs @@ -2,14 +2,11 @@ using Android.OS; using Android.OS.Storage; using Android.Systems; -using Android.Util; namespace SecureFolderFS.Core.MobileFS.Platforms.Android { public sealed class StorageManagerCompat { - private const string TAG = nameof(StorageManagerCompat); - private readonly StorageManager _storageManager; public StorageManagerCompat(Context context) @@ -17,85 +14,25 @@ public StorageManagerCompat(Context context) _storageManager = (StorageManager)context.GetSystemService(Context.StorageService)!; } - public static StorageManagerCompat From(Context context) => new StorageManagerCompat(context); - public ParcelFileDescriptor OpenProxyFileDescriptor( ParcelFileMode mode, ProxyFileDescriptorCallbackCompat callback, Handler handler) { - // if (Build.VERSION.SdkInt >= BuildVersionCodes.O) - // { - // return _storageManager.OpenProxyFileDescriptor( - // mode, - // callback.ToAndroidOsProxyFileDescriptorCallback(), - // handler - // ); - // } - - // Handle pre-Android O compatibility - if (mode != ParcelFileMode.ReadOnly && mode != ParcelFileMode.WriteOnly) - { - throw new NotSupportedException($"Mode {mode} is not supported."); - } - - var pipe = ParcelFileDescriptor.CreateReliablePipe(); - if (mode == ParcelFileMode.ReadOnly) - { - handler.Post(() => - { - try - { - using var outputStream = new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]); - var size = (int)callback.OnGetSize(); - var buffer = new byte[size]; - callback.OnRead(0, size, buffer); - outputStream.Write(buffer); - callback.OnRelease(); - } - catch (Exception e) - { - Log.Error(TAG, "Failed to read file.", e); - pipe[1].CloseWithError(e.Message); - } - }); - - return pipe[0]; - } - - if (mode == ParcelFileMode.WriteOnly) - { - handler.Post(() => - { - try - { - using var inputStream = new ParcelFileDescriptor.AutoCloseInputStream(pipe[0]); - var buffer = new byte[inputStream.Available()]; - inputStream.Read(buffer); - callback.OnWrite(0, buffer.Length, buffer); - callback.OnRelease(); - } - catch (Exception e) - { - Log.Error(TAG, "Failed to write file.", e); - pipe[0].CloseWithError(e.Message); - } - }); - - return pipe[1]; - } - - // Should never reach here - throw new NotSupportedException($"Mode {mode} is not supported."); + return _storageManager.OpenProxyFileDescriptor( + mode, + callback.ToAndroidOsProxyFileDescriptorCallback(), + handler + ); } public abstract class ProxyFileDescriptorCallbackCompat { public virtual long OnGetSize() => throw new ErrnoException("onGetSize", OsConstants.Ebadf); - public virtual int OnRead(long offset, int size, byte[] data) => throw new ErrnoException("onRead", OsConstants.Ebadf); + public virtual int OnRead(long offset, int size, byte[]? data) => throw new ErrnoException("onRead", OsConstants.Ebadf); - public virtual int OnWrite(long offset, int size, byte[] data) => throw new ErrnoException("onWrite", OsConstants.Ebadf); + public virtual int OnWrite(long offset, int size, byte[]? data) => throw new ErrnoException("onWrite", OsConstants.Ebadf); public virtual void OnFsync() => throw new ErrnoException("onFsync", OsConstants.Einval); @@ -106,24 +43,23 @@ public ProxyFileDescriptorCallback ToAndroidOsProxyFileDescriptorCallback() return new ProxyFileDescriptorCallbackWrapper(this); } - private class ProxyFileDescriptorCallbackWrapper : ProxyFileDescriptorCallback + private class ProxyFileDescriptorCallbackWrapper(ProxyFileDescriptorCallbackCompat compat) + : ProxyFileDescriptorCallback { - private readonly ProxyFileDescriptorCallbackCompat _compat; - - public ProxyFileDescriptorCallbackWrapper(ProxyFileDescriptorCallbackCompat compat) - { - _compat = compat; - } - - public override int OnRead(long offset, int size, byte[] data) => _compat.OnRead(offset, size, data); + /// + public override int OnRead(long offset, int size, byte[]? data) => compat.OnRead(offset, size, data); - public override int OnWrite(long offset, int size, byte[] data) => _compat.OnWrite(offset, size, data); + /// + public override int OnWrite(long offset, int size, byte[]? data) => compat.OnWrite(offset, size, data); - public override long OnGetSize() => _compat.OnGetSize(); + /// + public override long OnGetSize() => compat.OnGetSize(); - public override void OnFsync() => _compat.OnFsync(); + /// + public override void OnFsync() => compat.OnFsync(); - public override void OnRelease() => _compat.OnRelease(); + /// + public override void OnRelease() => compat.OnRelease(); } } } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs index d798486f0..bc5a32813 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs @@ -1,3 +1,4 @@ +using Android.OS; using Java.Nio; using Java.Nio.Channels; @@ -7,6 +8,7 @@ public sealed class ChannelledStream : Stream { private readonly FileChannel _inputChannel; private readonly FileChannel? _outputChannel; + private readonly ParcelFileDescriptor? _pfd; /// public override bool CanRead => _inputChannel.IsOpen; @@ -31,10 +33,11 @@ public override long Position } } - public ChannelledStream(FileChannel inputChannel, FileChannel? outputChannel) + public ChannelledStream(FileChannel inputChannel, FileChannel? outputChannel, ParcelFileDescriptor? pfd = null) { _inputChannel = inputChannel ?? throw new ArgumentNullException(nameof(inputChannel)); _outputChannel = outputChannel; + _pfd = pfd; } /// @@ -106,8 +109,9 @@ protected override void Dispose(bool disposing) { if (disposing) { - _inputChannel?.Close(); + _inputChannel.Close(); _outputChannel?.Close(); + _pfd?.Close(); } base.Dispose(disposing); diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/iOS/IOSFileSystem.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/iOS/IOSFileSystem.cs index 5afdafb44..cbd17054d 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/iOS/IOSFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/iOS/IOSFileSystem.cs @@ -6,12 +6,11 @@ using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Storage.Enums; using SecureFolderFS.Storage.VirtualFileSystem; -using IFileSystem = SecureFolderFS.Storage.VirtualFileSystem.IFileSystem; namespace SecureFolderFS.Core.MobileFS.Platforms.iOS { - /// - public sealed class IOSFileSystem : IFileSystem + /// + public sealed class IOSFileSystem : IFileSystemInfo { /// public string Id { get; } = Constants.IOS.FileSystem.FS_ID; @@ -26,18 +25,18 @@ public Task GetStatusAsync(CancellationToken cancellatio } /// - public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) + public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) { await Task.CompletedTask; if (unlockContract is not IWrapper wrapper) throw new ArgumentException($"The {nameof(unlockContract)} is invalid."); - var fileSystemOptions = VirtualFileSystemOptions.ToOptions(options.AppendContract(unlockContract), () => new HealthStatistics(), static () => new FileSystemStatistics()); + var fileSystemOptions = VirtualFileSystemOptions.ToOptions(options.AppendContract(unlockContract), static () => new HealthStatistics(), static () => new FileSystemStatistics()); var specifics = FileSystemSpecifics.CreateNew(wrapper.Inner, folder, fileSystemOptions); fileSystemOptions.SetupValidators(specifics); var storageRoot = new CryptoFolder(Path.DirectorySeparatorChar.ToString(), specifics.ContentFolder, specifics); - return new IOSVFSRoot(storageRoot, specifics); + return new IOSVfsRoot(storageRoot, specifics); } } } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/iOS/IOSVFSRoot.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/iOS/IOSVFSRoot.cs deleted file mode 100644 index 5c8c2a929..000000000 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/iOS/IOSVFSRoot.cs +++ /dev/null @@ -1,32 +0,0 @@ -using OwlCore.Storage; -using SecureFolderFS.Core.FileSystem; -using SecureFolderFS.Storage.VirtualFileSystem; - -namespace SecureFolderFS.Core.MobileFS.Platforms.iOS -{ - /// - internal sealed class IOSVFSRoot : VFSRoot - { - private bool _disposed; - - /// - public override string FileSystemName { get; } = Constants.IOS.FileSystem.FS_NAME; - - public IOSVFSRoot(IFolder storageRoot, FileSystemSpecifics specifics) - : base(storageRoot, specifics) - { - } - - /// - public override ValueTask DisposeAsync() - { - if (!_disposed) - { - _disposed = true; - FileSystemManager.Instance.FileSystems.Remove(this); - } - - return ValueTask.CompletedTask; - } - } -} diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/iOS/IOSVfsRoot.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/iOS/IOSVfsRoot.cs new file mode 100644 index 000000000..06c911ed7 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/iOS/IOSVfsRoot.cs @@ -0,0 +1,31 @@ +using OwlCore.Storage; +using SecureFolderFS.Core.FileSystem; +using SecureFolderFS.Storage.VirtualFileSystem; + +namespace SecureFolderFS.Core.MobileFS.Platforms.iOS +{ + /// + internal sealed class IOSVfsRoot : VfsRoot + { + private bool _disposed; + + /// + public override string FileSystemName { get; } = Constants.IOS.FileSystem.FS_NAME; + + public IOSVfsRoot(IFolder storageRoot, FileSystemSpecifics specifics) + : base(storageRoot, storageRoot, specifics) + { + } + + /// + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + FileSystemManager.Instance.FileSystems.Remove(this); + await base.DisposeAsync(); + } + } +} diff --git a/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj b/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj index f9c1e8a58..7472887a0 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj +++ b/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj @@ -1,16 +1,16 @@ - + net10.0-android;net10.0-ios - net10.0-ios + true true enable enable - 15.0 + 17.0 28.0 @@ -20,4 +20,10 @@ + + + false + + + diff --git a/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStore.cs b/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStore.cs deleted file mode 100644 index 149c6a4f8..000000000 --- a/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStore.cs +++ /dev/null @@ -1,56 +0,0 @@ -using NWebDav.Server.Locking; -using NWebDav.Server.Stores; -using SecureFolderFS.Core.FileSystem; -using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace SecureFolderFS.Core.WebDav.EncryptingStorage2 -{ - internal sealed class EncryptingDiskStore : DiskStore - { - private readonly FileSystemSpecifics _specifics; - - public EncryptingDiskStore(string directory, FileSystemSpecifics specifics, bool isWritable = true, ILockingManager? lockingManager = null) - : base(directory, isWritable, lockingManager) - { - _specifics = specifics; - } - - public override Task GetItemAsync(Uri uri, CancellationToken cancellationToken) - { - // Determine the path from the uri - var path = GetPathFromUri(uri); - - // Check if it's a directory - if (Directory.Exists(path)) - return Task.FromResult(new EncryptingDiskStoreCollection(LockingManager, new DirectoryInfo(path), IsWritable, _specifics)); - - // Check if it's a file - if (File.Exists(path)) - return Task.FromResult(new EncryptingDiskStoreFile(LockingManager, new FileInfo(path), IsWritable, _specifics)); - - // The item doesn't exist - return Task.FromResult(null); - } - - public override Task GetCollectionAsync(Uri uri, CancellationToken cancellationToken) - { - // Determine the path from the uri - var path = GetPathFromUri(uri); - if (!Directory.Exists(path)) - return Task.FromResult(null); - - // Return the item - return Task.FromResult(new EncryptingDiskStoreCollection(LockingManager, new DirectoryInfo(path), IsWritable, _specifics)); - } - - protected override string GetPathFromUri(Uri uri) - { - var path = base.GetPathFromUri(uri); - return NativePathHelpers.GetCiphertextPath(path, _specifics); - } - } -} diff --git a/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreCollection.cs b/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreCollection.cs deleted file mode 100644 index 91f135a1a..000000000 --- a/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreCollection.cs +++ /dev/null @@ -1,495 +0,0 @@ -using NWebDav.Server; -using NWebDav.Server.Enums; -using NWebDav.Server.Locking; -using NWebDav.Server.Props; -using NWebDav.Server.Stores; -using OwlCore.Storage; -using SecureFolderFS.Core.FileSystem; -using SecureFolderFS.Core.FileSystem.Helpers.Paths; -using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using System.Xml.Linq; -using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native; - -namespace SecureFolderFS.Core.WebDav.EncryptingStorage2 -{ - internal sealed class EncryptingDiskStoreCollection : IStoreCollection - { - private static readonly XElement s_xDavCollection = new XElement(WebDavNamespaces.DavNs + "collection"); - private readonly DirectoryInfo _directoryInfo; - private readonly FileSystemSpecifics _specifics; - - /// - public string Id { get; } - - /// - public string Name { get; } - - public EncryptingDiskStoreCollection(ILockingManager lockingManager, DirectoryInfo directoryInfo, bool isWritable, FileSystemSpecifics specifics) - { - _specifics = specifics; - _directoryInfo = directoryInfo; - - Id = NativePathHelpers.GetPlaintextPath(_directoryInfo.FullName, _specifics) ?? string.Empty; - Name = Path.GetFileName(Id); - LockingManager = lockingManager; - IsWritable = isWritable; - } - - /// - public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask; - cancellationToken.ThrowIfCancellationRequested(); - - switch (type) - { - case StorableType.File: - { - foreach (var file in _directoryInfo.GetFiles()) - { - if (PathHelpers.IsCoreName(file.Name)) - continue; - - yield return new DiskStoreFile(LockingManager, file, IsWritable); - } - - break; - } - - case StorableType.Folder: - { - foreach (var folder in _directoryInfo.GetDirectories()) - { - if (PathHelpers.IsCoreName(folder.Name)) - continue; - - yield return new DiskStoreCollection(LockingManager, folder, IsWritable); - } - - break; - } - - case StorableType.All: - { - foreach (var folder in _directoryInfo.GetDirectories()) - { - if (PathHelpers.IsCoreName(folder.Name)) - continue; - - - yield return new EncryptingDiskStoreCollection(LockingManager, folder, IsWritable, _specifics); - } - - foreach (var file in _directoryInfo.GetFiles()) - { - if (PathHelpers.IsCoreName(file.Name)) - continue; - - yield return new EncryptingDiskStoreFile(LockingManager, file, IsWritable, _specifics); - } - - break; - } - } - } - - /// - public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken) - { - await Task.CompletedTask; - cancellationToken.ThrowIfCancellationRequested(); - - // Determine the path - var id = NativePathHelpers.GetCiphertextPath(Path.Combine(Id, name), _specifics); - - // Check if the item is a file - if (File.Exists(id)) - return new EncryptingDiskStoreFile(LockingManager, new(id), IsWritable, _specifics); - - // Check if the item is a directory - if (Directory.Exists(id)) - return new EncryptingDiskStoreCollection(LockingManager, new(id), IsWritable, _specifics); - - // Item not found - throw new FileNotFoundException($"An item was not found. Name: '{name}'."); - } - - /// - public async Task MoveItemAsync(IStoreItem storeItem, IStoreCollection destinationCollection, string destinationName, bool overwrite, CancellationToken cancellationToken) - { - // Return error - if (!IsWritable) - throw new HttpListenerException((int)HttpStatusCode.PreconditionFailed); - - try - { - // If the destination collection is a directory too, then we can simply move the file - if (destinationCollection is EncryptingDiskStoreCollection destinationDiskStoreCollection) - { - // Return error - if (!destinationDiskStoreCollection.IsWritable) - throw new HttpListenerException((int)HttpStatusCode.PreconditionFailed); - - // Determine source and destination paths - var sourcePath = NativePathHelpers.GetCiphertextPath(storeItem.Id, _specifics); - var destinationPath = NativePathHelpers.GetCiphertextPath(Path.Combine(destinationDiskStoreCollection.Id, destinationName), _specifics); - - // Check if the file already exists - HttpStatusCode result; - if (File.Exists(destinationPath)) - { - // Remove the file if it already exists (if allowed) - if (!overwrite) - throw new HttpListenerException((int)HttpStatusCode.Forbidden); - - // The file will be overwritten - File.Delete(destinationPath); - result = HttpStatusCode.NoContent; - } - else if (Directory.Exists(destinationPath)) - { - // Remove the directory if it already exists (if allowed) - if (!overwrite) - throw new HttpListenerException((int)HttpStatusCode.Forbidden); - - // The file will be overwritten - Directory.Delete(destinationPath, true); - result = HttpStatusCode.NoContent; - } - else - { - // The file will be "created" - result = HttpStatusCode.Created; - } - - switch (storeItem) - { - case EncryptingDiskStoreFile _: - // Move the file - File.Move(sourcePath, destinationPath); - return new EncryptingDiskStoreFile(LockingManager, new FileInfo(destinationPath), IsWritable, _specifics); - - case EncryptingDiskStoreCollection _: - // Move the directory - Directory.Move(sourcePath, destinationPath); - return new EncryptingDiskStoreCollection(LockingManager, new DirectoryInfo(destinationPath), IsWritable, _specifics); - - default: - // Invalid item - Debug.Fail($"Invalid item {storeItem.GetType()} inside the {nameof(DiskStoreCollection)}."); - throw new HttpListenerException((int)HttpStatusCode.InternalServerError); - } - } - else - { - // Attempt to copy the item to the destination collection - var result = await storeItem.CopyAsync(destinationCollection, destinationName, overwrite, cancellationToken).ConfigureAwait(false); - if (result.Result == HttpStatusCode.Created || result.Result == HttpStatusCode.NoContent) - { - await DeleteAsync(storeItem, cancellationToken).ConfigureAwait(false); - return result.Item!; - } - else - { - throw new HttpListenerException((int)result.Result); - } - } - } - catch (UnauthorizedAccessException) - { - throw new HttpListenerException((int)HttpStatusCode.Forbidden); - } - } - - /// - public async Task DeleteAsync(IStoreItem storeItem, CancellationToken cancellationToken) - { - await Task.CompletedTask; - - // Return error - if (!IsWritable) - throw new HttpListenerException((int)HttpStatusCode.PreconditionFailed); - - // Determine the full path - var fullPath = NativePathHelpers.GetCiphertextPath(Path.Combine(Id, storeItem.Name), _specifics); - try - { - // Check if the file exists - if (File.Exists(fullPath)) - { - // Delete the file - NativeRecycleBinHelpers.DeleteOrRecycle(fullPath, _specifics, StorableType.File); - return; - } - - // Check if the directory exists - if (Directory.Exists(fullPath)) - { - // Delete the directory - NativeRecycleBinHelpers.DeleteOrRecycle(fullPath, _specifics, StorableType.Folder); - return; - } - - // Item not found - throw new HttpListenerException((int)HttpStatusCode.NotFound); - } - catch (UnauthorizedAccessException) - { - throw new HttpListenerException((int)HttpStatusCode.Forbidden); - } - catch (Exception) - { - // Log exception - // TODO(wd): Add logging - //s_log.Log(LogLevel.Error, () => $"Unable to delete '{fullPath}' directory.", exc); - throw new HttpListenerException((int)HttpStatusCode.InternalServerError); - } - } - - - - - public static PropertyManager DefaultPropertyManager { get; } = new(new DavProperty[] - { - // RFC-2518 properties - new DavCreationDate - { - Getter = (context, collection) => collection._directoryInfo.CreationTimeUtc, - Setter = (context, collection, value) => - { - collection._directoryInfo.CreationTimeUtc = value; - return HttpStatusCode.OK; - } - }, - new DavDisplayName - { - Getter = (context, collection) => - { - return collection._directoryInfo.Name == "content" - // Return the name of the root directory (Name will throw, as the content folder doesn't have a DirectoryID) - ? context.Request.Url.Segments[1] - : collection.Name; - } - }, - new DavGetLastModified - { - Getter = (context, collection) => collection._directoryInfo.LastWriteTimeUtc, - Setter = (context, collection, value) => - { - collection._directoryInfo.LastWriteTimeUtc = value; - return HttpStatusCode.OK; - } - }, - new DavGetResourceType - { - Getter = (context, collection) => new []{s_xDavCollection} - }, - - // Default locking property handling via the LockingManager - new DavLockDiscoveryDefault(), - new DavSupportedLockDefault(), - - // Hopmann/Lippert collection properties - new DavExtCollectionChildCount - { - Getter = (context, collection) => collection._directoryInfo.EnumerateFiles().Count() + collection._directoryInfo.EnumerateDirectories().Count() - }, - new DavExtCollectionIsFolder - { - Getter = (context, collection) => true - }, - new DavExtCollectionIsHidden - { - Getter = (context, collection) => (collection._directoryInfo.Attributes & FileAttributes.Hidden) != 0 - }, - new DavExtCollectionIsStructuredDocument - { - Getter = (context, collection) => false - }, - new DavExtCollectionHasSubs - { - Getter = (context, collection) => collection._directoryInfo.EnumerateDirectories().Any() - }, - new DavExtCollectionNoSubs - { - Getter = (context, collection) => false - }, - new DavExtCollectionObjectCount - { - Getter = (context, collection) => collection._directoryInfo.EnumerateFiles().Count() - }, - new DavExtCollectionReserved - { - Getter = (context, collection) => !collection.IsWritable - }, - new DavExtCollectionVisibleCount - { - Getter = (context, collection) => - collection._directoryInfo.EnumerateDirectories().Count(di => (di.Attributes & FileAttributes.Hidden) == 0) + - collection._directoryInfo.EnumerateFiles().Count(fi => (fi.Attributes & FileAttributes.Hidden) == 0) - }, - - // Win32 extensions - new Win32CreationTime - { - Getter = (context, collection) => collection._directoryInfo.CreationTimeUtc, - Setter = (context, collection, value) => - { - collection._directoryInfo.CreationTimeUtc = value; - return HttpStatusCode.OK; - } - }, - new Win32LastAccessTime - { - Getter = (context, collection) => collection._directoryInfo.LastAccessTimeUtc, - Setter = (context, collection, value) => - { - collection._directoryInfo.LastAccessTimeUtc = value; - return HttpStatusCode.OK; - } - }, - new Win32LastModifiedTime - { - Getter = (context, collection) => collection._directoryInfo.LastWriteTimeUtc, - Setter = (context, collection, value) => - { - collection._directoryInfo.LastWriteTimeUtc = value; - return HttpStatusCode.OK; - } - }, - new Win32FileAttributes - { - Getter = (context, collection) => collection._directoryInfo.Attributes, - Setter = (context, collection, value) => - { - collection._directoryInfo.Attributes = value; - return HttpStatusCode.OK; - } - } - }); - - public bool IsWritable { get; } - public IPropertyManager PropertyManager => DefaultPropertyManager; - public ILockingManager LockingManager { get; } - - public Task CreateItemAsync(string name, bool overwrite, CancellationToken cancellationToken) - { - // Return error - if (!IsWritable) - return Task.FromResult(new StoreItemResult(HttpStatusCode.Forbidden)); - - // Determine the destination path - var destinationPath = NativePathHelpers.GetCiphertextPath(Path.Combine(Id, name), _specifics); - - // Determine result - HttpStatusCode result; - - // Check if the file can be overwritten - if (File.Exists(name)) - { - if (!overwrite) - return Task.FromResult(new StoreItemResult(HttpStatusCode.PreconditionFailed)); - - result = HttpStatusCode.NoContent; - } - else - { - result = HttpStatusCode.Created; - } - - try - { - // Create a new file - File.Create(destinationPath).Dispose(); - } - catch (Exception exc) - { - // Log exception - // TODO(wd): Add logging - //s_log.Log(LogLevel.Error, () => $"Unable to create '{destinationPath}' file.", exc); - return Task.FromResult(new StoreItemResult(HttpStatusCode.InternalServerError)); - } - - // Return result - return Task.FromResult(new StoreItemResult(result, new EncryptingDiskStoreFile(LockingManager, new FileInfo(destinationPath), IsWritable, _specifics))); - } - - public Task CreateCollectionAsync(string name, bool overwrite, CancellationToken cancellationToken) - { - // Return error - if (!IsWritable) - return Task.FromResult(new StoreCollectionResult(HttpStatusCode.Forbidden)); - - // Determine the destination path - var destinationPath = NativePathHelpers.GetCiphertextPath(Path.Combine(Id, name), _specifics); - - // Check if the directory can be overwritten - HttpStatusCode result; - if (Directory.Exists(destinationPath)) - { - // Check if overwrite is allowed - if (!overwrite) - return Task.FromResult(new StoreCollectionResult(HttpStatusCode.MethodNotAllowed)); - - // Overwrite existing - result = HttpStatusCode.NoContent; - } - else - { - // Created new directory - result = HttpStatusCode.Created; - } - - try - { - // Attempt to create the directory - Directory.CreateDirectory(destinationPath); - - // Create new DirectoryID - var directoryId = Guid.NewGuid().ToByteArray(); - var directoryIdPath = Path.Combine(destinationPath, FileSystem.Constants.Names.DIRECTORY_ID_FILENAME); - - // Initialize directory with DirectoryID - using var directoryIdStream = File.Open(directoryIdPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete); - directoryIdStream.Write(directoryId); - - // Set DirectoryID to known IDs - _specifics.DirectoryIdCache.CacheSet(directoryIdPath, new(directoryId)); - } - catch (Exception exc) - { - // Log exception - // TODO(wd): Add logging - //s_log.Log(LogLevel.Error, () => $"Unable to create '{destinationPath}' directory.", exc); - return null; - } - - // Return the collection - return Task.FromResult(new StoreCollectionResult(result, new EncryptingDiskStoreCollection(LockingManager, new DirectoryInfo(destinationPath), IsWritable, _specifics))); - } - - public async Task CopyAsync(IStoreCollection destinationCollection, string name, bool overwrite, CancellationToken cancellationToken) - { - // Just create the folder itself - var result = await destinationCollection.CreateCollectionAsync(name, overwrite, cancellationToken).ConfigureAwait(false); - return new StoreItemResult(result.Result, result.Collection); - } - - public bool SupportsFastMove(IStoreCollection destination, string destinationName, bool overwrite) - { - // We can only move disk-store collections - return destination is EncryptingDiskStoreCollection; - } - - public EnumerationDepthMode InfiniteDepthMode => EnumerationDepthMode.Rejected; - } -} diff --git a/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreFile.cs b/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreFile.cs deleted file mode 100644 index a4c50e2d0..000000000 --- a/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreFile.cs +++ /dev/null @@ -1,230 +0,0 @@ -using NWebDav.Server.Helpers; -using NWebDav.Server.Locking; -using NWebDav.Server.Props; -using NWebDav.Server.Stores; -using SecureFolderFS.Core.FileSystem; -using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; -using System; -using System.IO; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace SecureFolderFS.Core.WebDav.EncryptingStorage2 -{ - internal class EncryptingDiskStoreFile : IStoreFile - { - private readonly FileSystemSpecifics _specifics; - private readonly FileInfo _fileInfo; - - public EncryptingDiskStoreFile(ILockingManager lockingManager, FileInfo fileInfo, bool isWritable, FileSystemSpecifics specifics) - { - LockingManager = lockingManager; - IsWritable = isWritable; - _fileInfo = fileInfo; - _specifics = specifics; - } - - public static PropertyManager DefaultPropertyManager { get; } = new(new DavProperty[] - { - // RFC-2518 properties - new DavCreationDate - { - Getter = (context, item) => item._fileInfo.CreationTimeUtc, - Setter = (context, item, value) => - { - item._fileInfo.CreationTimeUtc = value; - return HttpStatusCode.OK; - } - }, - new DavDisplayName - { - Getter = (context, item) => item.Name - }, - new DavGetContentLength - { - Getter = (context, item) => Math.Max(0, item._specifics.Security.ContentCrypt.CalculatePlaintextSize(item._fileInfo.Length - item._specifics.Security.HeaderCrypt.HeaderCiphertextSize)) - }, - new DavGetContentType - { - Getter = (context, item) => item.DetermineContentType() - }, - new DavGetEtag - { - Getter = (context, item) => $"{item._fileInfo.Length}-{item._fileInfo.LastWriteTimeUtc.ToFileTime()}" - }, - new DavGetLastModified - { - Getter = (context, item) => item._fileInfo.LastWriteTimeUtc, - Setter = (context, item, value) => - { - item._fileInfo.LastWriteTimeUtc = value; - return HttpStatusCode.OK; - } - }, - new DavGetResourceType - { - Getter = (context, item) => null - }, - - // Default locking property handling via the LockingManager - new DavLockDiscoveryDefault(), - new DavSupportedLockDefault(), - - // Hopmann/Lippert collection properties - // (although not a collection, the IsHidden property might be valuable) - new DavExtCollectionIsHidden - { - Getter = (context, item) => (item._fileInfo.Attributes & FileAttributes.Hidden) != 0 - }, - - // Win32 extensions - new Win32CreationTime - { - Getter = (context, item) => item._fileInfo.CreationTimeUtc, - Setter = (context, item, value) => - { - item._fileInfo.CreationTimeUtc = value; - return HttpStatusCode.OK; - } - }, - new Win32LastAccessTime - { - Getter = (context, item) => item._fileInfo.LastAccessTimeUtc, - Setter = (context, item, value) => - { - item._fileInfo.LastAccessTimeUtc = value; - return HttpStatusCode.OK; - } - }, - new Win32LastModifiedTime - { - Getter = (context, item) => item._fileInfo.LastWriteTimeUtc, - Setter = (context, item, value) => - { - item._fileInfo.LastWriteTimeUtc = value; - return HttpStatusCode.OK; - } - }, - new Win32FileAttributes - { - Getter = (context, item) => item._fileInfo.Attributes, - Setter = (context, item, value) => - { - item._fileInfo.Attributes = value; - return HttpStatusCode.OK; - } - } - }); - - public bool IsWritable { get; } - public string Name => Path.GetFileName(Id); - public string Id => NativePathHelpers.GetPlaintextPath(_fileInfo.FullName, _specifics) ?? string.Empty; - public Task GetReadableStreamAsync(CancellationToken cancellationToken) => Task.FromResult(_specifics.StreamsAccess.OpenPlaintextStream(_fileInfo.FullName, _fileInfo.OpenRead())); - public Task GetWritableStreamAsync(CancellationToken cancellationToken) => Task.FromResult(_specifics.StreamsAccess.OpenPlaintextStream(_fileInfo.FullName, _fileInfo.Open(FileMode.OpenOrCreate, FileAccess.ReadWrite))); - - public async Task UploadFromStreamAsync(Stream inputStream, CancellationToken cancellationToken) - { - // Check if the item is writable - if (!IsWritable) - return HttpStatusCode.Forbidden; - - // Copy the stream - try - { - // Copy the information to the destination stream - await using var outputStream = await GetWritableStreamAsync(cancellationToken).ConfigureAwait(false); - await inputStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); - return HttpStatusCode.OK; - } - catch (IOException ioException) when (ioException.IsDiskFull()) - { - return HttpStatusCode.InsufficientStorage; - } - catch (Exception ex) - { - _ = ex; - throw; - } - } - - public IPropertyManager PropertyManager => DefaultPropertyManager; - public ILockingManager LockingManager { get; } - - public async Task CopyAsync(IStoreCollection destination, string name, bool overwrite, CancellationToken cancellationToken) - { - try - { - // If the destination is also a disk-store, then we can use the FileCopy API - // (it's probably a bit more efficient than copying in C#) - if (destination is DiskStoreCollection diskCollection) - { - // Check if the collection is writable - if (!diskCollection.IsWritable) - return new StoreItemResult(HttpStatusCode.Forbidden); - - var destinationPath = NativePathHelpers.GetCiphertextPath(Path.Combine(diskCollection.Id, name), _specifics); - - // Check if the file already exists - var fileExists = File.Exists(destinationPath); - if (fileExists && !overwrite) - return new StoreItemResult(HttpStatusCode.PreconditionFailed); - - // Copy the file - File.Copy(_fileInfo.FullName, destinationPath, true); - - // Return the appropriate status - return new StoreItemResult(fileExists ? HttpStatusCode.NoContent : HttpStatusCode.Created); - } - else - { - // Create the item in the destination collection - var result = await destination.CreateItemAsync(name, overwrite, cancellationToken).ConfigureAwait(false); - - // Check if the item could be created - if (result.Item is IStoreFile storeFile) - { - using (var sourceStream = await GetWritableStreamAsync(cancellationToken).ConfigureAwait(false)) - { - var copyResult = await storeFile.UploadFromStreamAsync(sourceStream, cancellationToken).ConfigureAwait(false); - if (copyResult != HttpStatusCode.OK) - return new StoreItemResult(copyResult, result.Item); - } - } - else - { - // Item is directory - return new(HttpStatusCode.Conflict, result.Item); - } - - // Return result - return new StoreItemResult(result.Result, result.Item); - } - } - catch (Exception exc) - { - // TODO(wd): Add logging - //s_log.Log(LogLevel.Error, () => "Unexpected exception while copying data.", exc); - return new StoreItemResult(HttpStatusCode.InternalServerError); - } - } - - public override int GetHashCode() - { - return _fileInfo.FullName.GetHashCode(); - } - - public override bool Equals(object? obj) - { - if (obj is not EncryptingDiskStoreFile storeItem) - return false; - - return storeItem._fileInfo.FullName.Equals(_fileInfo.FullName, StringComparison.CurrentCultureIgnoreCase); - } - - private string DetermineContentType() - { - return MimeTypeHelper.GetMimeType(Name); - } - } -} diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs index 81cb395a6..24ba59f5f 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs @@ -1,27 +1,26 @@ -using NWebDav.Server; +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using NWebDav.Server; using NWebDav.Server.Dispatching; using NWebDav.Server.Storage; -using NWebDav.Server.Stores; using OwlCore.Storage; using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem; using SecureFolderFS.Core.FileSystem.Extensions; +using SecureFolderFS.Core.FileSystem.Storage; using SecureFolderFS.Core.WebDav.AppModels; -using SecureFolderFS.Core.WebDav.EncryptingStorage2; using SecureFolderFS.Core.WebDav.Helpers; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Storage.Enums; using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Core.WebDav { - /// - public abstract class WebDavFileSystem : IFileSystem + /// + public abstract class WebDavFileSystem : IFileSystemInfo { /// public string Id { get; } = Constants.FileSystem.FS_ID; @@ -37,7 +36,7 @@ public virtual Task GetStatusAsync(CancellationToken can } /// - public virtual async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) + public virtual async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) { if (unlockContract is not IWrapper wrapper) throw new ArgumentException($"The {nameof(unlockContract)} is invalid."); @@ -56,8 +55,10 @@ public virtual async Task MountAsync(IFolder folder, IDisposable unloc httpListener.Prefixes.Add(prefix); httpListener.AuthenticationSchemes = AuthenticationSchemes.Anonymous; - var encryptingDiskStore = new EncryptingDiskStore(specifics.ContentFolder.Id, specifics, !specifics.Options.IsReadOnly); - var dispatcher = new WebDavDispatcher(new RootDiskStore(specifics.Options.VolumeName, encryptingDiskStore), new RequestHandlerProvider(), null); + //var store = new EncryptingDiskStore(specifics.ContentFolder.Id, specifics, !specifics.Options.IsReadOnly); + var rootFolder = new CryptoFolder(specifics.ContentFolder.Id, specifics.ContentFolder, specifics); + var store = new BackedDavStore(rootFolder, !specifics.Options.IsReadOnly); + var dispatcher = new WebDavDispatcher(new RootDiskStore(specifics.Options.VolumeName, store), new RequestHandlerProvider(), null); return await MountAsync( specifics, @@ -67,7 +68,7 @@ public virtual async Task MountAsync(IFolder folder, IDisposable unloc cancellationToken); } - protected abstract Task MountAsync( + protected abstract Task MountAsync( FileSystemSpecifics specifics, HttpListener listener, WebDavOptions options, diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavRootFolder.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs similarity index 68% rename from src/Core/SecureFolderFS.Core.WebDav/WebDavRootFolder.cs rename to src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs index 07664a8ee..50313765e 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/WebDavRootFolder.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs @@ -5,8 +5,8 @@ namespace SecureFolderFS.Core.WebDav { - /// - public sealed class WebDavRootFolder : VFSRoot + /// + public sealed class WebDavVfsRoot : VfsRoot { private readonly WebDavWrapper _webDavWrapper; private bool _disposed; @@ -14,8 +14,8 @@ public sealed class WebDavRootFolder : VFSRoot /// public override string FileSystemName { get; } = Constants.FileSystem.FS_NAME; - public WebDavRootFolder(WebDavWrapper webDavWrapper, IFolder storageRoot, FileSystemSpecifics specifics) - : base(storageRoot, specifics) + public WebDavVfsRoot(WebDavWrapper webDavWrapper, IFolder virtualizedRoot, IFolder plaintextRoot, FileSystemSpecifics specifics) + : base(virtualizedRoot, plaintextRoot, specifics) { _webDavWrapper = webDavWrapper; } @@ -28,7 +28,10 @@ public override async ValueTask DisposeAsync() _disposed = await _webDavWrapper.CloseFileSystemAsync(); if (_disposed) + { FileSystemManager.Instance.FileSystems.Remove(this); + await base.DisposeAsync(); + } } } } diff --git a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs index 8ac62d3f2..5d1ba42d7 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs @@ -267,6 +267,12 @@ public override int GetSecurityByName( ? new DirectoryInfo(ciphertextPath) : new System.IO.FileInfo(ciphertextPath); + if (!info.Exists) + { + FileAttributes = 0; + return Trace(STATUS_OBJECT_NAME_NOT_FOUND, FileName); + } + // Set Attributes and Security Descriptor FileAttributes = (uint)info.Attributes; SecurityDescriptor = info switch @@ -507,7 +513,7 @@ public override int SetBasicInfo( dirHandle.DirectoryInfo.LastAccessTimeUtc = DateTime.FromFileTimeUtc((long)LastAccessTime); dirHandle.DirectoryInfo.Attributes = (FileAttributes)FileAttributes; FileInfo = dirHandle.GetFileInfo(); - + break; } @@ -902,7 +908,7 @@ public override void Cleanup( var directoryIdPath = Path.Combine(pathToDelete, FileSystem.Constants.Names.DIRECTORY_ID_FILENAME); _specifics.DirectoryIdCache.CacheRemove(directoryIdPath); - + break; } } diff --git a/src/Core/SecureFolderFS.Core.WinFsp/Constants.cs b/src/Core/SecureFolderFS.Core.WinFsp/Constants.cs index 17b8d2f28..3a9e584bb 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/Constants.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/Constants.cs @@ -7,6 +7,7 @@ public static class FileSystem public const string FS_ID = "WINFSP"; public const string FS_NAME = "WinFsp"; public const string VERSION_STRING = "2.1.x"; + public const string VERSION_TAG = "v2.1"; } internal static class WinFsp diff --git a/src/Core/SecureFolderFS.Core.WinFsp/WinFspFileSystem.Availability.cs b/src/Core/SecureFolderFS.Core.WinFsp/WinFspFileSystem.Availability.cs index 4ec7826bc..b940f6047 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/WinFspFileSystem.Availability.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/WinFspFileSystem.Availability.cs @@ -6,7 +6,7 @@ namespace SecureFolderFS.Core.WinFsp { - /// + /// public sealed partial class WinFspFileSystem { /// diff --git a/src/Core/SecureFolderFS.Core.WinFsp/WinFspFileSystem.cs b/src/Core/SecureFolderFS.Core.WinFsp/WinFspFileSystem.cs index 19b25d0b9..bd2a57151 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/WinFspFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/WinFspFileSystem.cs @@ -17,11 +17,12 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using SecureFolderFS.Core.FileSystem.Storage; namespace SecureFolderFS.Core.WinFsp { - /// - public sealed partial class WinFspFileSystem : IFileSystem + /// + public sealed partial class WinFspFileSystem : IFileSystemInfo { /// public string Id { get; } = Constants.FileSystem.FS_ID; @@ -33,7 +34,7 @@ public sealed partial class WinFspFileSystem : IFileSystem public partial Task GetStatusAsync(CancellationToken cancellationToken = default); /// - public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) + public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default) { await Task.CompletedTask; if (unlockContract is not IWrapper wrapper) @@ -55,14 +56,17 @@ public async Task MountAsync(IFolder folder, IDisposable unlockContrac var driveInfo = new DriveInfo(pathRoot); var winFspCallbacks = new OnDeviceWinFsp(specifics, handlesManager, volumeModel, driveInfo); //var winFsp = new WinFspService(winFspCallbacks, winFspOptions.MountPoint); - var winFsp = new WinFspHost(winFspCallbacks, winFspOptions.MountPoint); + var winFsp = new WinFspHost(winFspCallbacks, winFspOptions.MountPoint); var result = await winFsp.StartFileSystemAsync(); if (!result.Successful) throw new Win32Exception(result.Value); winFspOptions.DangerousSetMountPoint(winFsp.GetMountPointInternal()); - return new WinFspVFSRoot(winFsp, new SystemFolderEx(winFspOptions.MountPoint), specifics); + + var virtualizedRoot = new SystemFolderEx(winFspOptions.MountPoint); + var plaintextRoot = new CryptoFolder(Path.DirectorySeparatorChar.ToString(), specifics.ContentFolder, specifics); + return new WinFspVfsRoot(winFsp, virtualizedRoot, plaintextRoot, specifics); } } } diff --git a/src/Core/SecureFolderFS.Core.WinFsp/WinFspVFSRoot.cs b/src/Core/SecureFolderFS.Core.WinFsp/WinFspVfsRoot.cs similarity index 74% rename from src/Core/SecureFolderFS.Core.WinFsp/WinFspVFSRoot.cs rename to src/Core/SecureFolderFS.Core.WinFsp/WinFspVfsRoot.cs index 948636389..0caa059c1 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/WinFspVFSRoot.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/WinFspVfsRoot.cs @@ -6,8 +6,8 @@ namespace SecureFolderFS.Core.WinFsp { - /// - internal sealed class WinFspVFSRoot : VFSRoot + /// + internal sealed class WinFspVfsRoot : VfsRoot { private readonly WinFspHost _winFsp; private bool _disposed; @@ -15,8 +15,8 @@ internal sealed class WinFspVFSRoot : VFSRoot /// public override string FileSystemName { get; } = Constants.FileSystem.FS_NAME; - public WinFspVFSRoot(WinFspHost winFsp, IFolder storageRoot, FileSystemSpecifics specifics) - : base(storageRoot, specifics) + public WinFspVfsRoot(WinFspHost winFsp, IFolder virtualizedRoot, IFolder plaintextRoot, FileSystemSpecifics specifics) + : base(virtualizedRoot, plaintextRoot, specifics) { _winFsp = winFsp; } diff --git a/src/Core/SecureFolderFS.Core/Constants.cs b/src/Core/SecureFolderFS.Core/Constants.cs index a0098faf1..e48d1dc16 100644 --- a/src/Core/SecureFolderFS.Core/Constants.cs +++ b/src/Core/SecureFolderFS.Core/Constants.cs @@ -20,11 +20,11 @@ public static class Authentication public const string AUTH_PASSWORD = "password"; public const string AUTH_KEYFILE = "key_file"; public const string AUTH_WINDOWS_HELLO = "windows_hello"; - public const string AUTH_HARDWARE_KEY = "hardware_key"; + public const string AUTH_YUBIKEY = "yubikey"; + public const string AUTH_APPLE_MACOS = "apple_macos_biometrics"; public const string AUTH_APPLE_BIOMETRIC = "apple_secure_enclave"; public const string AUTH_ANDROID_BIOMETRIC = "android_biometrics"; - - public const string AUTH_DEVICE_PING = "device_ping"; + public const string AUTH_DEVICE_LINK = "device_link"; } [Obsolete] @@ -54,6 +54,7 @@ public static class Versions public const int V1 = 1; public const int V2 = 2; public const int V3 = 3; + public const int V4 = 4; public const int LATEST_VERSION = V3; } } diff --git a/src/Core/SecureFolderFS.Core/DataModels/VaultKeystoreDataModel.cs b/src/Core/SecureFolderFS.Core/DataModels/V3VaultKeystoreDataModel.cs similarity index 92% rename from src/Core/SecureFolderFS.Core/DataModels/VaultKeystoreDataModel.cs rename to src/Core/SecureFolderFS.Core/DataModels/V3VaultKeystoreDataModel.cs index 54cff8338..be419f881 100644 --- a/src/Core/SecureFolderFS.Core/DataModels/VaultKeystoreDataModel.cs +++ b/src/Core/SecureFolderFS.Core/DataModels/V3VaultKeystoreDataModel.cs @@ -4,7 +4,7 @@ namespace SecureFolderFS.Core.DataModels { [Serializable] - public sealed record class VaultKeystoreDataModel + public sealed record class V3VaultKeystoreDataModel { /// /// Gets the wrapped version of the DEK key. diff --git a/src/Core/SecureFolderFS.Core/DataModels/V4VaultKeystoreDataModel.cs b/src/Core/SecureFolderFS.Core/DataModels/V4VaultKeystoreDataModel.cs new file mode 100644 index 000000000..7b527fb7b --- /dev/null +++ b/src/Core/SecureFolderFS.Core/DataModels/V4VaultKeystoreDataModel.cs @@ -0,0 +1,50 @@ +using System; +using System.Text.Json.Serialization; + +namespace SecureFolderFS.Core.DataModels +{ + [Serializable] + public sealed record class V4VaultKeystoreDataModel + { + /// + /// Gets the wrapped version of the DEK key. + /// + [JsonPropertyName("c_encryptionKey")] + public byte[]? WrappedDekKey { get; init; } + + /// + /// Gets the wrapped version of the MAC key. + /// + [JsonPropertyName("c_macKey")] + public byte[]? WrappedMacKey { get; init; } + + /// + /// Gets the salt used during password hashing. + /// + [JsonPropertyName("salt")] + public byte[]? Salt { get; init; } + + /// + /// Gets the AES-256-GCM ciphertext of the 256-bit SoftwareEntropy value. + /// SoftwareEntropy is a CSPRNG secret generated at vault creation that is mixed + /// into the Argon2id input via HKDF-Extract, raising the quantum security floor + /// of all authentication methods to 256 bits regardless of auth factor entropy. + /// It is encrypted under a key derived from the passkey so all active auth + /// factors are required to recover it. + /// + [JsonPropertyName("c_softwareEntropy")] + public byte[]? EncryptedSoftwareEntropy { get; init; } + + /// + /// Gets the nonce used when encrypting . + /// + [JsonPropertyName("entropyNonce")] + public byte[]? SoftwareEntropyNonce { get; init; } + + /// + /// Gets the AES-256-GCM authentication tag for . + /// + [JsonPropertyName("entropyTag")] + public byte[]? SoftwareEntropyTag { get; init; } + } +} \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core/DataModels/VaultChallengeComplementation.cs b/src/Core/SecureFolderFS.Core/DataModels/VaultChallengeComplementation.cs index 2d50099de..cb86868d2 100644 --- a/src/Core/SecureFolderFS.Core/DataModels/VaultChallengeComplementation.cs +++ b/src/Core/SecureFolderFS.Core/DataModels/VaultChallengeComplementation.cs @@ -12,6 +12,6 @@ public record class VaultChallengeComplementation : VaultChallengeDataModel /// [JsonPropertyName("complementation")] [DefaultValue(null)] - private VaultKeystoreDataModel? ComplementedKeystore { get; set; } + private V3VaultKeystoreDataModel? ComplementedKeystore { get; set; } } } diff --git a/src/Core/SecureFolderFS.Core/Routines/ICredentialsRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/ICredentialsRoutine.cs index ba6864dd3..9aa0a13ad 100644 --- a/src/Core/SecureFolderFS.Core/Routines/ICredentialsRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/ICredentialsRoutine.cs @@ -1,10 +1,10 @@ -using SecureFolderFS.Core.Cryptography.SecureStore; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Core.Routines { // TODO: Needs docs public interface ICredentialsRoutine : IFinalizationRoutine { - void SetCredentials(SecretKey passkey); + void SetCredentials(IKeyUsage passkey); } } diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/CreationRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/CreationRoutine.cs index d13f182d3..74af213c9 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/CreationRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/CreationRoutine.cs @@ -8,6 +8,7 @@ using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; +using SecureFolderFS.Shared.ComponentModel; using static SecureFolderFS.Core.Constants.Vault; using static SecureFolderFS.Core.Cryptography.Constants; @@ -18,10 +19,11 @@ internal sealed class CreationRoutine : ICreationRoutine { private readonly IFolder _vaultFolder; private readonly VaultWriter _vaultWriter; - private VaultKeystoreDataModel? _keystoreDataModel; + private V3VaultKeystoreDataModel? _keystoreDataModel; + private V4VaultKeystoreDataModel? _v4KeystoreDataModel; private VaultConfigurationDataModel? _configDataModel; - private SecretKey? _macKey; - private SecretKey? _dekKey; + private IKeyUsage? _dekKey; + private IKeyUsage? _macKey; public CreationRoutine(IFolder vaultFolder, VaultWriter vaultWriter) { @@ -36,25 +38,44 @@ public Task InitAsync(CancellationToken cancellationToken = default) } /// - public void SetCredentials(SecretKey passkey) + public void SetCredentials(IKeyUsage passkey) { - // Allocate shallow keys which will be later disposed of - using var dekKey = new SecureKey(KeyTraits.DEK_KEY_LENGTH); - using var macKey = new SecureKey(KeyTraits.MAC_KEY_LENGTH); + // Allocate keys for later use + var dekKey = new byte[KeyTraits.DEK_KEY_LENGTH]; + var macKey = new byte[KeyTraits.MAC_KEY_LENGTH]; var salt = new byte[KeyTraits.SALT_LENGTH]; // Fill keys - using var secureRandom = RandomNumberGenerator.Create(); - secureRandom.GetNonZeroBytes(dekKey.Key); - secureRandom.GetNonZeroBytes(macKey.Key); - secureRandom.GetNonZeroBytes(salt); + RandomNumberGenerator.Fill(dekKey); + RandomNumberGenerator.Fill(macKey); + RandomNumberGenerator.Fill(salt); // Generate keystore - _keystoreDataModel = VaultParser.EncryptKeystore(passkey, dekKey, macKey, salt); + _keystoreDataModel = passkey.UseKey(key => VaultParser.V3EncryptKeystore(key, dekKey, macKey, salt)); // Create key copies for later use - _macKey = macKey.CreateCopy(); - _dekKey = dekKey.CreateCopy(); + _dekKey = SecureKey.TakeOwnership(dekKey); + _macKey = SecureKey.TakeOwnership(macKey); + } + + public void V4SetCredentials(IKeyUsage passkey) + { + // Allocate keys for later use + var dekKey = new byte[KeyTraits.DEK_KEY_LENGTH]; + var macKey = new byte[KeyTraits.MAC_KEY_LENGTH]; + var salt = new byte[KeyTraits.SALT_LENGTH]; + + // Fill keys and salt + RandomNumberGenerator.Fill(dekKey); + RandomNumberGenerator.Fill(macKey); + RandomNumberGenerator.Fill(salt); + + // Generate V4 keystore — SoftwareEntropy is generated internally by V4EncryptKeystore + _v4KeystoreDataModel = passkey.UseKey(key => VaultParser.V4EncryptKeystore(key, dekKey, macKey, salt)); + + // Create key copies for later use + _dekKey = SecureKey.TakeOwnership(dekKey); + _macKey = SecureKey.TakeOwnership(macKey); } /// @@ -71,14 +92,18 @@ public async Task FinalizeAsync(CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(_macKey); ArgumentNullException.ThrowIfNull(_dekKey); - // First we need to fill in the PayloadMac of the content - VaultParser.CalculateConfigMac(_configDataModel, _macKey, _configDataModel.PayloadMac); + // First, we need to fill in the PayloadMac of the content + _macKey.UseKey(macKey => + { + 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); - // Create content folder + // Create the content folder if (_vaultFolder is IModifiableFolder modifiableFolder) await modifiableFolder.CreateFolderAsync(Names.VAULT_CONTENT_FOLDERNAME, true, cancellationToken); diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs index c6ba2b0aa..7345717b2 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs @@ -1,34 +1,40 @@ -using SecureFolderFS.Core.Cryptography; +using System; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.Cryptography.SecureStore; using SecureFolderFS.Core.DataModels; using SecureFolderFS.Core.Models; using SecureFolderFS.Core.VaultAccess; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; -using System; -using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Core.Routines.Operational { /// internal sealed class ModifyCredentialsRoutine : IModifyCredentialsRoutine { + private readonly VaultReader _vaultReader; private readonly VaultWriter _vaultWriter; private KeyPair? _keyPair; - private VaultKeystoreDataModel? _keystoreDataModel; + private V4VaultKeystoreDataModel? _existingV4KeystoreDataModel; + private V3VaultKeystoreDataModel? _keystoreDataModel; + private V4VaultKeystoreDataModel? _v4KeystoreDataModel; private VaultConfigurationDataModel? _configDataModel; - public ModifyCredentialsRoutine(VaultWriter vaultWriter) + public ModifyCredentialsRoutine(VaultReader vaultReader, VaultWriter vaultWriter) { + _vaultReader = vaultReader; _vaultWriter = vaultWriter; } /// - public Task InitAsync(CancellationToken cancellationToken = default) + public async Task InitAsync(CancellationToken cancellationToken = default) { - return Task.CompletedTask; + await Task.CompletedTask; + //_existingV4KeystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); } /// @@ -47,17 +53,79 @@ public void SetOptions(VaultOptions vaultOptions) } /// - public void SetCredentials(SecretKey passkey) + public unsafe void SetCredentials(IKeyUsage passkey) { ArgumentNullException.ThrowIfNull(_keyPair); // Generate new salt - using var secureRandom = RandomNumberGenerator.Create(); var salt = new byte[Cryptography.Constants.KeyTraits.SALT_LENGTH]; - secureRandom.GetNonZeroBytes(salt); + RandomNumberGenerator.Fill(salt); + + // Encrypt a new keystore + passkey.UseKey(key => + { + fixed (byte* keyPtr = key) + { + var state = (keyPtr: (nint)keyPtr, keyLen: key.Length); + _keyPair.UseKeys(state, (dekKey, macKey, s) => + { + var k = new ReadOnlySpan((byte*)s.keyPtr, s.keyLen); + _keystoreDataModel = VaultParser.V3EncryptKeystore(k, dekKey, macKey, salt); + }); + } + }); + } + + [SkipLocalsInit] + public unsafe void V4SetCredentials(IKeyUsage oldPasskey, IKeyUsage newPasskey, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(_keyPair); + ArgumentNullException.ThrowIfNull(_existingV4KeystoreDataModel); + + // Generate new salt for the re-encrypted keystore + var salt = new byte[Cryptography.Constants.KeyTraits.SALT_LENGTH]; + RandomNumberGenerator.Fill(salt); + + // Decrypt existing SoftwareEntropy using the old passkey, then re-encrypt + // it under the new passkey alongside the (unchanged) DEK and MAC keys. + // SoftwareEntropy must be preserved - regenerating it would change the KEK + // derivation and make the vault permanently unreadable. + Span softwareEntropy = stackalloc byte[32]; + try + { + fixed (byte* softwareEntropyPtr = softwareEntropy) + { + var state = (sePtr: (nint)softwareEntropyPtr, seLen: softwareEntropy.Length); + oldPasskey.UseKey(state, (oldKey, s) => + { + var se = new Span((byte*)s.sePtr, s.seLen); + VaultParser.V4DecryptSoftwareEntropy(oldKey, _existingV4KeystoreDataModel, se); + }); + } + + fixed (byte* softwareEntropyPtr = softwareEntropy) + { + var state = (sePtr: (nint)softwareEntropyPtr, seLen: softwareEntropy.Length); + newPasskey.UseKey(state, (newKey, s) => + { + fixed (byte* newKeyPtr = newKey) + { + var state2 = (nkPtr: (nint)newKeyPtr, nkLen: newKey.Length, outerState: state); + _keyPair.UseKeys(state2, (dekKey, macKey, s2) => + { + var nk = new ReadOnlySpan((byte*)s2.nkPtr, s2.nkLen); + var se = new Span((byte*)s2.outerState.sePtr, s2.outerState.seLen); - // Encrypt new keystore - _keystoreDataModel = VaultParser.EncryptKeystore(passkey, _keyPair.DekKey, _keyPair.MacKey, salt); + _v4KeystoreDataModel = VaultParser.V4ReEncryptKeystore(nk, dekKey, macKey, salt, se); + }); + } + }); + } + } + finally + { + CryptographicOperations.ZeroMemory(softwareEntropy); + } } /// @@ -66,16 +134,20 @@ public async Task FinalizeAsync(CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(_keyPair); ArgumentNullException.ThrowIfNull(_configDataModel); - // First we need to fill in the PayloadMac of the content - VaultParser.CalculateConfigMac(_configDataModel, _keyPair.MacKey, _configDataModel.PayloadMac); + // First, we need to fill in the PayloadMac of the content + _keyPair.MacKey.UseKey(macKey => + { + 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); // Key copies need to be created because the original ones are disposed of here using (_keyPair) - return new SecurityWrapper(KeyPair.ImportKeys(_keyPair.DekKey, _keyPair.MacKey), _configDataModel); + return new SecurityWrapper(_keyPair.CreateCopy(), _configDataModel); } /// diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs index 1258e0fca..a12e7f748 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs @@ -1,11 +1,12 @@ -using SecureFolderFS.Core.Cryptography.SecureStore; +using System; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Core.Cryptography.SecureStore; using SecureFolderFS.Core.DataModels; using SecureFolderFS.Core.Models; using SecureFolderFS.Core.Validators; using SecureFolderFS.Core.VaultAccess; -using System; -using System.Threading; -using System.Threading.Tasks; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Core.Routines.Operational { @@ -28,7 +29,7 @@ public async Task InitAsync(CancellationToken cancellationToken) } /// - public void SetCredentials(SecretKey passkey) + public void SetCredentials(IKeyUsage passkey) { _keyPair = KeyPair.CopyFromRecoveryKey(passkey); } @@ -41,16 +42,13 @@ public async Task FinalizeAsync(CancellationToken cancellationToken using (_keyPair) { - // Create MAC key copy for the validator that can be disposed here - using var macKeyCopy = _keyPair.MacKey.CreateCopy(); - // Check if the payload has not been tampered with - var validator = new ConfigurationValidator(macKeyCopy); + var validator = new ConfigurationValidator(_keyPair.MacKey); 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 - return new SecurityWrapper(KeyPair.ImportKeys(_keyPair.DekKey, _keyPair.MacKey), _configDataModel); + return new SecurityWrapper(_keyPair.CreateCopy(), _configDataModel); } } diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs index 20af7f455..6a3b8c195 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs @@ -1,11 +1,12 @@ -using SecureFolderFS.Core.Cryptography.SecureStore; +using System; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Core.Cryptography.SecureStore; using SecureFolderFS.Core.DataModels; using SecureFolderFS.Core.Models; using SecureFolderFS.Core.Validators; using SecureFolderFS.Core.VaultAccess; -using System; -using System.Threading; -using System.Threading.Tasks; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Core.Routines.Operational { @@ -13,10 +14,11 @@ namespace SecureFolderFS.Core.Routines.Operational internal sealed class UnlockRoutine : ICredentialsRoutine { private readonly VaultReader _vaultReader; - private VaultKeystoreDataModel? _keystoreDataModel; + private V3VaultKeystoreDataModel? _keystoreDataModel; + private V4VaultKeystoreDataModel? _v4KeystoreDataModel; private VaultConfigurationDataModel? _configDataModel; - private SecretKey? _dekKey; - private SecretKey? _macKey; + private SecureKey? _dekKey; + private SecureKey? _macKey; public UnlockRoutine(VaultReader vaultReader) { @@ -26,20 +28,34 @@ public UnlockRoutine(VaultReader vaultReader) /// public async Task InitAsync(CancellationToken cancellationToken) { - _keystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); _configDataModel = await _vaultReader.ReadConfigurationAsync(cancellationToken); + + if (_configDataModel.Version >= Constants.Vault.Versions.V4) + _v4KeystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); + else + _keystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); } /// - public void SetCredentials(SecretKey passkey) + public void SetCredentials(IKeyUsage passkey) { ArgumentNullException.ThrowIfNull(_configDataModel); - ArgumentNullException.ThrowIfNull(_keystoreDataModel); - // Derive keystore - var (dekKey, macKey) = VaultParser.DeriveKeystore(passkey, _keystoreDataModel); - _dekKey = dekKey; - _macKey = macKey; + if (_v4KeystoreDataModel is not null) + { + var derived = passkey.UseKey(key => VaultParser.V4DeriveKeystore(key, _v4KeystoreDataModel)); + _dekKey = SecureKey.TakeOwnership(derived.dekKey); + _macKey = SecureKey.TakeOwnership(derived.macKey); + } + else + { + ArgumentNullException.ThrowIfNull(_keystoreDataModel); + + // V3 path: unchanged + var derived = passkey.UseKey(key => VaultParser.V3DeriveKeystore(key, _keystoreDataModel)); + _dekKey = SecureKey.TakeOwnership(derived.dekKey); + _macKey = SecureKey.TakeOwnership(derived.macKey); + } } /// @@ -53,11 +69,8 @@ public async Task FinalizeAsync(CancellationToken cancellationToken using (_dekKey) using (_macKey) { - // Create MAC key copy for the validator that can be disposed here - using var macKeyCopy = _macKey.CreateCopy(); - // Check if the payload has not been tampered with - var validator = new ConfigurationValidator(macKeyCopy); + var validator = new ConfigurationValidator(_macKey); await validator.ValidateAsync(_configDataModel, cancellationToken); // In this case, we rely on the consumer to take ownership of the keys, and thus manage their lifetimes diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs index 671907549..07f10296e 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs @@ -48,7 +48,7 @@ public ICredentialsRoutine RecoverVault() public IModifyCredentialsRoutine ModifyCredentials() { CheckVaultValidation(); - return new ModifyCredentialsRoutine(VaultWriter); + return new ModifyCredentialsRoutine(VaultReader, VaultWriter); } private void CheckVaultValidation() diff --git a/src/Core/SecureFolderFS.Core/Validators/ConfigurationValidator.cs b/src/Core/SecureFolderFS.Core/Validators/ConfigurationValidator.cs index 419ff00b9..3019a25b9 100644 --- a/src/Core/SecureFolderFS.Core/Validators/ConfigurationValidator.cs +++ b/src/Core/SecureFolderFS.Core/Validators/ConfigurationValidator.cs @@ -1,36 +1,45 @@ -using SecureFolderFS.Core.Cryptography.SecureStore; -using SecureFolderFS.Core.DataModels; -using SecureFolderFS.Core.VaultAccess; -using SecureFolderFS.Shared.ComponentModel; -using System; +using System; using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; +using SecureFolderFS.Core.DataModels; +using SecureFolderFS.Core.VaultAccess; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Core.Validators { internal sealed class ConfigurationValidator : IAsyncValidator { - private readonly SecretKey _macKey; + private readonly IKeyUsage _macKey; - public ConfigurationValidator(SecretKey macKey) + public ConfigurationValidator(IKeyUsage macKey) { _macKey = macKey; } /// + public async Task ValidateAsync(VaultConfigurationDataModel value, CancellationToken cancellationToken = default) + { + Validate(value); + await Task.CompletedTask; + } + [SkipLocalsInit] - public Task ValidateAsync(VaultConfigurationDataModel value, CancellationToken cancellationToken = default) + private void Validate(VaultConfigurationDataModel value) { - Span payloadMac = stackalloc byte[HMACSHA256.HashSizeInBytes]; - VaultParser.CalculateConfigMac(value, _macKey, payloadMac); + var isEqual = _macKey.UseKey(macKey => + { + Span payloadMac = stackalloc byte[HMACSHA256.HashSizeInBytes]; + VaultParser.CalculateConfigMac(value, macKey, payloadMac); - // Check if stored hash equals to computed hash - if (!payloadMac.SequenceEqual(value.PayloadMac)) - return Task.FromException(new CryptographicException("Vault hash doesn't match the computed hash.")); + // Check if stored hash equals to computed hash using constant-time comparison to prevent timing attacks + return CryptographicOperations.FixedTimeEquals(payloadMac, value.PayloadMac); + }); - return Task.CompletedTask; + // Confirm that the hashes are equal + 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 83ac71665..7b7b1afe0 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs @@ -18,10 +18,10 @@ public static class VaultParser /// The to compute the thumbprint for. /// The key part of HMAC. /// The destination to fill the calculated HMAC thumbprint into. - public static void CalculateConfigMac(VaultConfigurationDataModel configDataModel, SecretKey macKey, Span mac) + public static void CalculateConfigMac(VaultConfigurationDataModel configDataModel, ReadOnlySpan macKey, Span mac) { // Initialize HMAC - using var hmacSha256 = new HMACSHA256(macKey.Key); + using var hmacSha256 = new HMACSHA256(macKey.ToArray()); // Update HMAC hmacSha256.AppendData(BitConverter.GetBytes(configDataModel.Version)); // Version @@ -43,41 +43,41 @@ public static void CalculateConfigMac(VaultConfigurationDataModel configDataMode /// The keystore that holds wrapped keys. /// A tuple containing the DEK and MAC keys respectively. [SkipLocalsInit] - public static (SecretKey dekKey, SecretKey macKey) DeriveKeystore(SecretKey passkey, VaultKeystoreDataModel keystoreDataModel) + public static (byte[] dekKey, byte[] macKey) V3DeriveKeystore(ReadOnlySpan passkey, V3VaultKeystoreDataModel keystoreDataModel) { - var dekKey = new SecureKey(Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH); - var macKey = new SecureKey(Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH); + var dekKey = new byte[Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH]; + var macKey = new byte[Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH]; // Derive KEK Span kek = stackalloc byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; - Argon2id.V3_DeriveKey(passkey.Key, keystoreDataModel.Salt, kek); + Argon2id.DeriveKey(passkey, keystoreDataModel.Salt, kek); // Unwrap keys using var rfc3394 = new Rfc3394KeyWrap(); - rfc3394.UnwrapKey(keystoreDataModel.WrappedDekKey, kek, dekKey.Key); - rfc3394.UnwrapKey(keystoreDataModel.WrappedMacKey, kek, macKey.Key); + rfc3394.UnwrapKey(keystoreDataModel.WrappedDekKey, kek, dekKey); + rfc3394.UnwrapKey(keystoreDataModel.WrappedMacKey, kek, macKey); return (dekKey, macKey); } /// - /// Encrypts cryptographic keys and creates a new instance of . + /// Encrypts cryptographic keys and creates a new instance of . /// /// The passkey credential that combines password and 'magic'. /// The DEK key. /// The MAC key. /// The salt used during KEK derivation. - /// A new instance of containing the encrypted cryptographic keys. + /// A new instance of containing the encrypted cryptographic keys. [SkipLocalsInit] - public static VaultKeystoreDataModel EncryptKeystore( - SecretKey passkey, - SecretKey dekKey, - SecretKey macKey, + public static V3VaultKeystoreDataModel V3EncryptKeystore( + ReadOnlySpan passkey, + ReadOnlySpan dekKey, + ReadOnlySpan macKey, byte[] salt) { // Derive KEK Span kek = stackalloc byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; - Argon2id.V3_DeriveKey(passkey, salt, kek); + Argon2id.DeriveKey(passkey, salt, kek); // Wrap keys using var rfc3394 = new Rfc3394KeyWrap(); @@ -92,5 +92,220 @@ public static VaultKeystoreDataModel EncryptKeystore( Salt = salt }; } + + /// + /// Derives DEK and MAC keys from provided credentials for a vault. + /// Decrypts using the + /// raw passkey, then mixes it into the Argon2id input via HKDF-Extract before + /// deriving the KEK. This raises the quantum security floor to 256 bits regardless + /// of the entropy of the auth factor feeding the passkey. + /// + /// The passkey credential that combines all active auth factor outputs. + /// The keystore that holds wrapped keys. + /// A tuple containing the DEK and MAC keys respectively. + [SkipLocalsInit] + public static (byte[] dekKey, byte[] macKey) V4DeriveKeystore(ReadOnlySpan passkey, V4VaultKeystoreDataModel keystoreDataModel) + { + ArgumentNullException.ThrowIfNull(keystoreDataModel.Salt); + ArgumentNullException.ThrowIfNull(keystoreDataModel.EncryptedSoftwareEntropy); + ArgumentNullException.ThrowIfNull(keystoreDataModel.SoftwareEntropyNonce); + ArgumentNullException.ThrowIfNull(keystoreDataModel.SoftwareEntropyTag); + + var dekKey = new byte[Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH]; + var macKey = new byte[Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH]; + + // Step 1: Decrypt SoftwareEntropy using a key derived from the raw passkey. + // The bootstrap key is derived from the passkey alone (not the augmented key) + // so that recovering SoftwareEntropy always requires all active auth factors. + Span bootstrapKey = stackalloc byte[32]; + HKDF.DeriveKey( + HashAlgorithmName.SHA256, + passkey, + bootstrapKey, + keystoreDataModel.Salt, // Salt ties the bootstrap key to this specific keystore + "SFFSv4-EntropyBootstrap-v1"u8); + + Span softwareEntropy = stackalloc byte[keystoreDataModel.EncryptedSoftwareEntropy.Length]; + using (var aes = new AesGcm(bootstrapKey, 16)) + { + aes.Decrypt( + keystoreDataModel.SoftwareEntropyNonce, + keystoreDataModel.EncryptedSoftwareEntropy, + keystoreDataModel.SoftwareEntropyTag, + softwareEntropy); + } + + try + { + // Step 2: Mix passkey and SoftwareEntropy via HKDF-Extract. + // passkey is IKM; SoftwareEntropy is salt. + // Breaking either alone is insufficient to reproduce the augmented key. + Span augmentedPasskey = stackalloc byte[32]; + HKDF.DeriveKey( + HashAlgorithmName.SHA256, + passkey, + augmentedPasskey, + softwareEntropy, + "SFFSv4-AugmentedPasskey-v1"u8); + + // Step 3: Derive KEK from the augmented passkey + Span kek = stackalloc byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; + Argon2id.DeriveKey(augmentedPasskey, keystoreDataModel.Salt, kek); + + // Step 4: Unwrap keys + using var rfc3394 = new Rfc3394KeyWrap(); + rfc3394.UnwrapKey(keystoreDataModel.WrappedDekKey, kek, dekKey); + rfc3394.UnwrapKey(keystoreDataModel.WrappedMacKey, kek, macKey); + } + finally + { + CryptographicOperations.ZeroMemory(softwareEntropy); + } + + return (dekKey, macKey); + } + + /// + /// Encrypts cryptographic keys and creates a new instance of . + /// Generates and encrypts a fresh + /// which is mixed into Argon2id input at unlock time to raise the quantum security floor. + /// + /// The passkey credential that combines all active auth factor outputs. + /// The DEK key. + /// The MAC key. + /// The salt used during KEK derivation. + /// A new instance of containing the encrypted cryptographic keys and entropy. + [SkipLocalsInit] + public static V4VaultKeystoreDataModel V4EncryptKeystore( + ReadOnlySpan passkey, + ReadOnlySpan dekKey, + ReadOnlySpan macKey, + byte[] salt) + { + // Step 1: Generate fresh SoftwareEntropy (256-bit CSPRNG) + Span softwareEntropy = stackalloc byte[32]; + RandomNumberGenerator.Fill(softwareEntropy); + + return V4EncryptKeystoreWithEntropy(passkey, dekKey, macKey, salt, softwareEntropy); + } + + /// + /// Re-encrypts cryptographic keys into a new while + /// preserving the existing . Used during credential + /// changes so the vault remains accessible after the passkey changes. + /// + /// The new passkey credential. + /// The DEK key (unchanged from the existing keystore). + /// The MAC key (unchanged from the existing keystore). + /// A freshly generated salt for the new keystore. + /// The plaintext SoftwareEntropy recovered from the old keystore. + /// A new with re-encrypted keys and entropy. + [SkipLocalsInit] + public static V4VaultKeystoreDataModel V4ReEncryptKeystore( + ReadOnlySpan passkey, + ReadOnlySpan dekKey, + ReadOnlySpan macKey, + byte[] salt, + ReadOnlySpan existingSoftwareEntropy) + { + return V4EncryptKeystoreWithEntropy(passkey, dekKey, macKey, salt, existingSoftwareEntropy); + } + + /// + /// Decrypts the from an existing + /// keystore using the current passkey. Used during credential changes to recover the entropy + /// value before re-encrypting it under the new passkey. + /// + /// The current (old) passkey. + /// The existing V4 keystore. + /// The destination span to fill with the decrypted entropy (must be 32 bytes). + public static void V4DecryptSoftwareEntropy( + ReadOnlySpan passkey, + V4VaultKeystoreDataModel keystoreDataModel, + Span softwareEntropy) + { + ArgumentNullException.ThrowIfNull(keystoreDataModel.Salt); + ArgumentNullException.ThrowIfNull(keystoreDataModel.EncryptedSoftwareEntropy); + ArgumentNullException.ThrowIfNull(keystoreDataModel.SoftwareEntropyNonce); + ArgumentNullException.ThrowIfNull(keystoreDataModel.SoftwareEntropyTag); + + Span bootstrapKey = stackalloc byte[32]; + HKDF.DeriveKey( + HashAlgorithmName.SHA256, + passkey, + bootstrapKey, + keystoreDataModel.Salt, + "SFFSv4-EntropyBootstrap-v1"u8); + + using var aes = new AesGcm(bootstrapKey, 16); + aes.Decrypt( + keystoreDataModel.SoftwareEntropyNonce, + keystoreDataModel.EncryptedSoftwareEntropy, + keystoreDataModel.SoftwareEntropyTag, + softwareEntropy); + } + + /// + /// Shared implementation for both and . + /// Encrypts the provided entropy under the passkey and wraps DEK/MAC under the augmented KEK. + /// + [SkipLocalsInit] + private static V4VaultKeystoreDataModel V4EncryptKeystoreWithEntropy( + ReadOnlySpan passkey, + ReadOnlySpan dekKey, + ReadOnlySpan macKey, + byte[] salt, + ReadOnlySpan softwareEntropy) + { + // Step 1: Encrypt SoftwareEntropy under a bootstrap key derived from the raw passkey. + // Using the raw passkey (not the augmented one) means decrypting entropy + // always requires all active auth factors — same guarantee at both creation and unlock. + Span bootstrapKey = stackalloc byte[32]; + HKDF.DeriveKey( + HashAlgorithmName.SHA256, + passkey, + bootstrapKey, + salt, + "SFFSv4-EntropyBootstrap-v1"u8); + + var entropyNonce = new byte[12]; + var entropyTag = new byte[16]; + var encryptedEntropy = new byte[softwareEntropy.Length]; + RandomNumberGenerator.Fill(entropyNonce); + + using (var aes = new AesGcm(bootstrapKey, 16)) + { + aes.Encrypt(entropyNonce, softwareEntropy, encryptedEntropy, entropyTag); + } + + // Step 2: Augment passkey with SoftwareEntropy via HKDF-Extract before Argon2id. + // This is the same derivation performed at unlock in V4DeriveKeystore. + Span augmentedPasskey = stackalloc byte[32]; + HKDF.DeriveKey( + HashAlgorithmName.SHA256, + passkey, + augmentedPasskey, + softwareEntropy, + "SFFSv4-AugmentedPasskey-v1"u8); + + // Step 3: Derive KEK from augmented passkey + Span kek = stackalloc byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; + Argon2id.DeriveKey(augmentedPasskey, salt, kek); + + // Step 4: Wrap keys + using var rfc3394 = new Rfc3394KeyWrap(); + var wrappedDekKey = rfc3394.WrapKey(dekKey, kek); + var wrappedMacKey = rfc3394.WrapKey(macKey, kek); + + return new() + { + WrappedDekKey = wrappedDekKey, + WrappedMacKey = wrappedMacKey, + Salt = salt, + EncryptedSoftwareEntropy = encryptedEntropy, + SoftwareEntropyNonce = entropyNonce, + SoftwareEntropyTag = entropyTag + }; + } } } diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs index 444fbff08..ffa8a326a 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs @@ -23,13 +23,17 @@ public VaultReader(IFolder vaultFolder, IAsyncSerializer serializer) _serializer = serializer; } - public async Task ReadKeystoreAsync(CancellationToken cancellationToken) + /// + /// Reads the keystore as the specified type. + /// + public async Task ReadKeystoreAsync(CancellationToken cancellationToken) + where TKeystore : class { - // Get keystore file + // Get the keystore file if (await _vaultFolder.GetFirstByNameAsync(Constants.Vault.Names.VAULT_KEYSTORE_FILENAME, cancellationToken) is not IFile keystoreFile) throw new FileNotFoundException("The keystore file was not found."); - return await ReadDataAsync(keystoreFile, _serializer, cancellationToken); + return await ReadDataAsync(keystoreFile, _serializer, cancellationToken); } public async Task ReadConfigurationAsync(CancellationToken cancellationToken) diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs index e35e608df..92943e572 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs @@ -20,7 +20,11 @@ public VaultWriter(IFolder vaultFolder, IAsyncSerializer serializer) _serializer = serializer; } - public async Task WriteKeystoreAsync(VaultKeystoreDataModel? keystoreDataModel, CancellationToken cancellationToken) + /// + /// Writes the keystore as the specified type. + /// + public async Task WriteKeystoreAsync(TKeystore? keystoreDataModel, CancellationToken cancellationToken) + where TKeystore : class { var keystoreFile = keystoreDataModel is null ? null : _vaultFolder switch { diff --git a/src/Platforms/Directory.Build.props b/src/Platforms/Directory.Build.props index e874bba80..3c1723a71 100644 --- a/src/Platforms/Directory.Build.props +++ b/src/Platforms/Directory.Build.props @@ -62,7 +62,7 @@ true - 15.0 + 17.0 diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props index d0379f17e..f519dc960 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -1,29 +1,34 @@ - - - - - + + + + + + - + - - - - - + + + + + - - + + + + + + - + @@ -38,6 +43,8 @@ + + - + \ No newline at end of file diff --git a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj index b10d1dbdb..1bbadf973 100644 --- a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj +++ b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable diff --git a/src/Platforms/SecureFolderFS.Maui/AndroidManifest.xml b/src/Platforms/SecureFolderFS.Maui/AndroidManifest.xml deleted file mode 100644 index b31496818..000000000 --- a/src/Platforms/SecureFolderFS.Maui/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/Platforms/SecureFolderFS.Maui/App.xaml.cs b/src/Platforms/SecureFolderFS.Maui/App.xaml.cs index d2115b0b1..e4e9a8281 100644 --- a/src/Platforms/SecureFolderFS.Maui/App.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/App.xaml.cs @@ -1,23 +1,37 @@ +using System.Globalization; using APES.UI.XF; using SecureFolderFS.Maui.Extensions.Mappers; using SecureFolderFS.Maui.Helpers; +using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Views.Root; using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Helpers; using SecureFolderFS.UI.Helpers; +#if IOS || MACCATALYST +using SecureFolderFS.Maui.Platforms.iOS.Helpers; +using SecureFolderFS.Maui.Platforms.iOS.Templates; +#elif ANDROID +using SecureFolderFS.Maui.Platforms.Android.Helpers; +using SecureFolderFS.Maui.Platforms.Android.Templates; +#endif + namespace SecureFolderFS.Maui { public partial class App : Application { public static App Instance => (App)Current!; + public MainViewModel MainViewModel { get; } = new(new VaultCollectionModel()); + public IServiceProvider? ServiceProvider { get; private set; } public BaseLifecycleHelper ApplicationLifecycle { get; } = #if ANDROID - new Platforms.Android.Helpers.AndroidLifecycleHelper(); + new AndroidLifecycleHelper(); #elif IOS - new Platforms.iOS.Helpers.IOSLifecycleHelper(); + new IOSLifecycleHelper(); #else null; #endif @@ -31,10 +45,10 @@ public App() #if ANDROID // Load Android-specific resource dictionaries - Resources.MergedDictionaries.Add(new Platforms.Android.Templates.AndroidDataTemplates()); + Resources.MergedDictionaries.Add(new AndroidDataTemplates()); #elif IOS // Load IOS-specific resource dictionaries - Resources.MergedDictionaries.Add(new Platforms.iOS.Templates.IOSDataTemplates()); + Resources.MergedDictionaries.Add(new IOSDataTemplates()); #endif // Configure mappers @@ -51,11 +65,14 @@ protected override Window CreateWindow(IActivationState? activationState) { ContextMenuContainer.Init(); - var appShell = Task.Run(GetAppShellAsync).ConfigureAwait(false).GetAwaiter().GetResult(); - return new Window(appShell); + // Run initialization on a background thread (no UI work here) + Task.Run(InitializeAppAsync).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Create AppShell on the main thread where XAML initialization is safe + return new Window(new AppShell()); } - private async Task GetAppShellAsync() + private async Task InitializeAppAsync() { // Initialize application lifecycle await ApplicationLifecycle.InitAsync(); @@ -66,18 +83,36 @@ private async Task GetAppShellAsync() // Register IoC DI.Default.SetServiceProvider(ServiceProvider); + // Determine app language + await SafetyHelpers.NoFailureAsync(async () => + { + if (!Preferences.Default.ContainsKey("IsAppLanguageDetected")) + { + // Check the current system language and find it in AppLanguages + // If it doesn't exist, use en-US + var localizationService = DI.Service(); + var systemCulture = CultureInfo.CurrentUICulture; + var appLanguages = localizationService.AppLanguages; + var matchedLanguage = appLanguages.FirstOrDefault(lang => lang.Name.Equals(systemCulture.Name, StringComparison.OrdinalIgnoreCase)) + ?? appLanguages.FirstOrDefault(lang => lang.TwoLetterISOLanguageName.Equals(systemCulture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase)) + ?? appLanguages.FirstOrDefault(lang => lang.Name.Equals("en-US", StringComparison.OrdinalIgnoreCase)); + + if (matchedLanguage is not null) + await localizationService.SetCultureAsync(matchedLanguage); + + Preferences.Default.Set("IsAppLanguageDetected", true); + } + }); + // Initialize Telemetry var telemetryService = DI.Service(); await telemetryService.EnableTelemetryAsync(); - // Create and initialize AppShell - var appShell = new AppShell(); - await appShell.MainViewModel.InitAsync().ConfigureAwait(false); + // Initialize MainViewModel + await MainViewModel.InitAsync(); // Initialize ThemeHelper await MauiThemeHelper.Instance.InitAsync().ConfigureAwait(false); - - return appShell; } /// diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/CorrectedPanGestureRecognizer.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/CorrectedPanGestureRecognizer.cs index f69e9efc6..7a9395f6f 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/CorrectedPanGestureRecognizer.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/CorrectedPanGestureRecognizer.cs @@ -1,6 +1,8 @@ // Some parts of the following code were used from https://github.com/dotnet/maui/issues/20772#issuecomment-2030914069 +#if ANDROID using Microsoft.Maui.Platform; +#endif namespace SecureFolderFS.Maui.AppModels { @@ -16,12 +18,11 @@ internal sealed class CorrectedPanGestureRecognizer : PanGestureRecognizer, IPan void IPanGestureController.SendPan(Element sender, double totalX, double totalY, int gestureId) { #if ANDROID - ArgumentNullException.ThrowIfNull(sender.Handler.MauiContext?.Context); - Android.Views.View view = sender.ToPlatform(sender.Handler.MauiContext); + ArgumentNullException.ThrowIfNull(sender.Handler?.MauiContext?.Context); + var view = sender.ToPlatform(sender.Handler.MauiContext); view.GetLocationOnScreen(currentLocation); totalX += sender.Handler.MauiContext.Context.FromPixels(currentLocation[0] - startingLocation[0]); totalY += sender.Handler.MauiContext.Context.FromPixels(currentLocation[1] - startingLocation[1]); - #endif PanUpdated?.Invoke(sender, new PanUpdatedEventArgs(GestureStatus.Running, gestureId, totalX, totalY)); } @@ -39,8 +40,8 @@ void IPanGestureController.SendPanCompleted(Element sender, int gestureId) void IPanGestureController.SendPanStarted(Element sender, int gestureId) { #if ANDROID - ArgumentNullException.ThrowIfNull(sender.Handler.MauiContext); - Android.Views.View view = sender.ToPlatform(sender.Handler.MauiContext); + ArgumentNullException.ThrowIfNull(sender.Handler?.MauiContext); + var view = sender.ToPlatform(sender.Handler.MauiContext); view.GetLocationOnScreen(startingLocation); #endif PanUpdated?.Invoke(sender, new PanUpdatedEventArgs(GestureStatus.Started, gestureId)); diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuItem.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuItem.cs deleted file mode 100644 index 55844fd85..000000000 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuItem.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SecureFolderFS.Maui.AppModels -{ - public sealed class ExMenuItem : ExMenuItemBase - { - - } -} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuItemBase.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuItemBase.cs deleted file mode 100644 index d700922b4..000000000 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuItemBase.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace SecureFolderFS.Maui.AppModels -{ - public abstract class ExMenuItemBase : ToolbarItem - { - // TODO: Better icon source - // TODO: On Android, secondary item icon source - } -} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuItemSeparator.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuItemSeparator.cs deleted file mode 100644 index 286cccc31..000000000 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuItemSeparator.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SecureFolderFS.Maui.AppModels -{ - public sealed class ExMenuItemSeparator : ExMenuItemBase - { - - } -} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuSubItem.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuSubItem.cs deleted file mode 100644 index 17c4f2638..000000000 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ExMenuSubItem.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SecureFolderFS.Maui.AppModels -{ - public sealed class ExMenuSubItem : ExMenuItemBase - { - - } -} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageResourceFile.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageResourceFile.cs deleted file mode 100644 index 652a036ed..000000000 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageResourceFile.cs +++ /dev/null @@ -1,23 +0,0 @@ -using IImage = SecureFolderFS.Shared.ComponentModel.IImage; - -namespace SecureFolderFS.Maui.AppModels -{ - /// - internal sealed class ImageResourceFile : IImage - { - public string Name { get; } - - public bool IsResource { get; } - - public ImageResourceFile(string name, bool isResource) - { - Name = name; - IsResource = isResource; - } - - /// - public void Dispose() - { - } - } -} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs index 9cc0a7053..c1c04f375 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStream.cs @@ -1,5 +1,6 @@ using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.Streams; using IImage = SecureFolderFS.Shared.ComponentModel.IImage; namespace SecureFolderFS.Maui.AppModels @@ -23,14 +24,16 @@ public async Task CopyToAsync(Stream destination, CancellationToken cancellation { var savedPosition = Stream.Position; await Stream.CopyToAsync(destination, cancellationToken); - Stream.Position = savedPosition; + + if (Stream.CanSeek) + Stream.Position = savedPosition; } /// public void Dispose() { - if (Stream is OnDemandDisposableStream onDemandDisposableStream) - onDemandDisposableStream.ForceClose(); + if (Stream is NonDisposableStream nonDisposableStream) + nonDisposableStream.ForceClose(); else Stream.Dispose(); } diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/MauiOAuthHandler.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/MauiOAuthHandler.cs index 0911f5391..1b0902aa2 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/MauiOAuthHandler.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/MauiOAuthHandler.cs @@ -11,7 +11,7 @@ namespace SecureFolderFS.Maui.AppModels internal sealed class MauiOAuthHandler : IOAuthHandler { public static IOAuthHandler Instance { get; } = new MauiOAuthHandler(); - + private int _port; private HttpListener? _httpListener; private readonly SemaphoreSlim _portSemaphore = new(1, 1); @@ -30,10 +30,10 @@ public string RedirectUrl { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); - + var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); - + _port = port; } } @@ -42,11 +42,11 @@ public string RedirectUrl _portSemaphore.Release(); } } - + return $"http://localhost:{_port}/"; } } - + private MauiOAuthHandler() { } @@ -54,9 +54,17 @@ private MauiOAuthHandler() /// public async Task> GetCodeAsync(string url, CancellationToken cancellationToken = default) { - // Ensure the port is set (this will trigger the RedirectUri getter) - var redirectUri = RedirectUrl; + return await GetCodeInternalAsync(url, RedirectUrl, cancellationToken); + } + /// + public async Task> GetCodeAsync(string url, string redirectUri, CancellationToken cancellationToken = default) + { + return await GetCodeInternalAsync(url, redirectUri, cancellationToken); + } + + private async Task> GetCodeInternalAsync(string url, string redirectUri, CancellationToken cancellationToken) + { // Start HTTP listener on localhost _httpListener = new HttpListener(); _httpListener.Prefixes.Add(redirectUri); @@ -81,7 +89,7 @@ public async Task> GetCodeAsync(string url, CancellationTok var state = queryParams.Get("state"); var error = queryParams.Get("error"); - // Prepare a authorization result webpage + // Prepare the authorization result webpage if (code is null) { await using var stream = await FileSystem.OpenAppPackageFileAsync("auth_fail.html"); @@ -96,7 +104,7 @@ public async Task> GetCodeAsync(string url, CancellationTok { await using var stream = await FileSystem.OpenAppPackageFileAsync("auth_success.html"); using var reader = new StreamReader(stream); - + responseString = await reader.ReadToEndAsync(cancellationToken); } @@ -104,7 +112,7 @@ public async Task> GetCodeAsync(string url, CancellationTok var buffer = Encoding.UTF8.GetBytes(responseString); response.ContentLength64 = buffer.Length; response.ContentType = "text/html"; - + // Write and close await response.OutputStream.WriteAsync(buffer, cancellationToken); response.OutputStream.Close(); diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs index ec1c10253..474505dbb 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs @@ -99,7 +99,6 @@ async Task BeginListeningAsync() await response.OutputStream.FlushAsync(cancellationToken); response.StatusCode = (int)HttpStatusCode.OK; response.StatusDescription = "OK"; - #endif } } diff --git a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml index 31ccebe6f..d5375fae4 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml +++ b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml @@ -5,19 +5,10 @@ xmlns:vw="clr-namespace:SecureFolderFS.Maui.Views" Title="SecureFolderFS" FlyoutBehavior="Disabled" + ForegroundColor="{StaticResource PrimaryContrastingDarkColor}" Loaded="AppShell_Loaded" - Shell.NavBarIsVisible="True"> - - - - #ffffff - - - - - #f0f0f0 - - + NavBarIsVisible="True" + TitleColor="#f0f0f0"> diff --git a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs index a899d09a8..c2ab1f13d 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs @@ -1,19 +1,18 @@ +using CommunityToolkit.Mvvm.Messaging; using SecureFolderFS.Maui.Views.Vault; -using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Messages; using SecureFolderFS.Sdk.Services; -using SecureFolderFS.Sdk.ViewModels; using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.UI.Helpers; namespace SecureFolderFS.Maui { - public partial class AppShell : Shell + public partial class AppShell : Shell, IRecipient { - public MainViewModel MainViewModel { get; } = new(new VaultCollectionModel()); - public AppShell() { InitializeComponent(); @@ -23,23 +22,53 @@ public AppShell() Routing.RegisterRoute("OverviewPage", typeof(OverviewPage)); Routing.RegisterRoute("BrowserPage", typeof(BrowserPage)); Routing.RegisterRoute("HealthPage", typeof(HealthPage)); + + WeakReferenceMessenger.Default.Register(this); + } + + /// + public void Receive(VaultLockedMessage message) + { + Vibration.Vibrate(200d); } private async void AppShell_Loaded(object? sender, EventArgs e) { - var sessionException = ExceptionHelpers.RetrieveSessionFile(App.Instance.ApplicationLifecycle.AppDirectory); - if (sessionException is null) - return; + var settingsService = DI.Service(); + if (!settingsService.AppSettings.WasIntroduced) + { + var overlayService = DI.Service(); + await overlayService.ShowAsync(new IntroductionOverlayViewModel().WithInitAsync()); - var overlayService = DI.Service(); - var messageOverlay = new MessageOverlayViewModel() + settingsService.AppSettings.WasIntroduced = true; + await settingsService.AppSettings.TrySaveAsync(); + } + + await SafetyHelpers.NoFailureAsync(async () => { - Title = "ClosedUnexpectedly".ToLocalized(nameof(SecureFolderFS)), - PrimaryText = "Close".ToLocalized(), - Message = sessionException - }; + var sessionException = ExceptionHelpers.RetrieveSessionFile(App.Instance.ApplicationLifecycle.AppDirectory); + if (sessionException is null) + return; + + var overlayService = DI.Service(); + var messageOverlay = new MessageOverlayViewModel() + { + Title = "ClosedUnexpectedly".ToLocalized(nameof(SecureFolderFS)), + PrimaryText = "Copy".ToLocalized(), + SecondaryText = "Close".ToLocalized(), + Message = sessionException + }; + + var result = await overlayService.ShowAsync(messageOverlay); + if (!result.Positive()) + return; + + var clipboardService = DI.Service(); + await clipboardService.SetTextAsync(sessionException); + }); - await overlayService.ShowAsync(messageOverlay); + // Initialize DeviceLink + await SafetyHelpers.NoFailureAsync(async () => await DeviceLinkCredentialsOverlayViewModel.Instance.InitAsync()); } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs index 0ce7d7ac8..5d45f3ce7 100644 --- a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs @@ -25,7 +25,7 @@ public static void AddEntryMappers() var roundRectShape = new RoundRectShape(outerRadii, null, null); var shape = new ShapeDrawable(roundRectShape); - shape.Paint!.Color = (App.Instance.Resources[MauiThemeHelper.Instance.CurrentTheme switch + shape.Paint!.Color = (App.Instance.Resources[MauiThemeHelper.Instance.ActualTheme switch { ThemeType.Dark => "BorderDarkColor", _ => "BorderLightColor" @@ -35,7 +35,7 @@ public static void AddEntryMappers() handler.PlatformView.Background = shape; handler.PlatformView.SetPadding(40, 32,40, 32); #elif IOS - handler.PlatformView.Layer.BorderColor = (App.Current.Resources[MauiThemeHelper.Instance.CurrentTheme switch + handler.PlatformView.Layer.BorderColor = (App.Current.Resources[MauiThemeHelper.Instance.ActualTheme switch { ThemeType.Dark => "BorderDarkColor", _ => "BorderLightColor" diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs index 02aba0257..1647daf47 100644 --- a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs @@ -1,17 +1,17 @@ +using Microsoft.Maui.Handlers; +using Microsoft.Maui.Platform; +using SecureFolderFS.Maui.UserControls.Common; +using SecureFolderFS.Shared.Helpers; +using SecureFolderFS.UI.Enums; +using SecureFolderFS.Maui.Helpers; #if ANDROID using Android.Graphics.Drawables.Shapes; -using SecureFolderFS.Maui.Helpers; using Paint = Android.Graphics.Paint; using ShapeDrawable = Android.Graphics.Drawables.ShapeDrawable; -#elif IOS +#elif IOS || MACCATALYST using UIKit; using CoreGraphics; #endif -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using SecureFolderFS.Maui.Helpers; -using SecureFolderFS.Maui.UserControls.Common; -using SecureFolderFS.UI.Enums; namespace SecureFolderFS.Maui.Extensions.Mappers { @@ -33,49 +33,92 @@ public static void AddPickerMappers() shape.Paint!.Color = modernPicker.IsTransparent ? Colors.Transparent.ToPlatform() : (App.Instance.Resources["ThemeSecondaryColorBrush"] as SolidColorBrush)!.Color.ToPlatform(); + shape.Paint.StrokeWidth = 0; shape.Paint.SetStyle(Paint.Style.FillAndStroke); - handler.PlatformView.SetTextColor((App.Instance.Resources[MauiThemeHelper.Instance.CurrentTheme switch - { - ThemeType.Dark => "QuarternaryDarkColor", - _ => "QuarternaryLightColor" - }] as Color)!.ToPlatform()); handler.PlatformView.Background = shape; handler.PlatformView.SetPadding(32, 24, 32, 24); + + void ApplyAndroidColors() + { + SafetyHelpers.NoFailure(() => + { + if (!modernPicker.IsTransparent) + shape.Paint!.Color = (App.Instance.Resources["ThemeSecondaryColorBrush"] as SolidColorBrush)!.Color.ToPlatform(); + + handler.PlatformView.SetTextColor((App.Instance.Resources[MauiThemeHelper.Instance.ActualTheme switch + { + ThemeType.Dark => "QuarternaryDarkColor", + _ => "QuarternaryLightColor" + }] as Color)!.ToPlatform()); + }); + } + + ApplyAndroidColors(); + + void OnThemeChangedAndroid(object? s, EventArgs _) => ApplyAndroidColors(); + void OnUnloadedAndroid(object? s, EventArgs _) + { + MauiThemeHelper.Instance.ActualThemeChanged -= OnThemeChangedAndroid; + modernPicker.Unloaded -= OnUnloadedAndroid; + } + + MauiThemeHelper.Instance.ActualThemeChanged += OnThemeChangedAndroid; + modernPicker.Unloaded += OnUnloadedAndroid; + #elif IOS || MACCATALYST var uiTextField = handler.PlatformView; - + // Remove border uiTextField.BorderStyle = UITextBorderStyle.None; - - // Set background color - uiTextField.BackgroundColor = modernPicker.IsTransparent - ? UIColor.Clear - : (App.Instance.Resources["ThemeSecondaryColorBrush"] as SolidColorBrush)!.Color.ToPlatform(); - - // Set text color - uiTextField.TextColor = (App.Instance.Resources[MauiThemeHelper.Instance.CurrentTheme switch - { - ThemeType.Dark => "PrimaryContrastingDarkColor", - _ => "PrimaryContrastingLightColor" - }] as Color)!.ToPlatform(); - + uiTextField.Layer.CornerRadius = 8f; + uiTextField.Layer.MasksToBounds = true; + // Add the chevron icon on the right var chevronConfig = UIImageSymbolConfiguration.Create(UIImageSymbolScale.Small); var chevronImage = UIImage.GetSystemImage("chevron.up.chevron.down", chevronConfig); var chevronImageView = new UIImageView(chevronImage) { - ContentMode = UIViewContentMode.ScaleAspectFit, - TintColor = uiTextField.TextColor + ContentMode = UIViewContentMode.ScaleAspectFit }; - + // Create a container for the chevron with padding var rightView = new UIView(new CGRect(0, 0, 30, 20)); chevronImageView.Frame = new CGRect(6, 2, 16, 16); rightView.AddSubview(chevronImageView); - + uiTextField.RightView = rightView; uiTextField.RightViewMode = UITextFieldViewMode.Always; + + void ApplyIosColors() + { + SafetyHelpers.NoFailure(() => + { + uiTextField.BackgroundColor = modernPicker.IsTransparent + ? UIColor.Clear + : (App.Instance.Resources["ThemeSecondaryColorBrush"] as SolidColorBrush)!.Color.ToPlatform(); + + uiTextField.TextColor = (App.Instance.Resources[MauiThemeHelper.Instance.ActualTheme switch + { + ThemeType.Dark => "PrimaryContrastingDarkColor", + _ => "PrimaryContrastingLightColor" + }] as Color)!.ToPlatform(); + + chevronImageView.TintColor = uiTextField.TextColor; + }); + } + + ApplyIosColors(); + + void OnThemeChangedIos(object? s, EventArgs _) => ApplyIosColors(); + void OnUnloadedIos(object? s, EventArgs _) + { + MauiThemeHelper.Instance.ActualThemeChanged -= OnThemeChangedIos; + modernPicker.Unloaded -= OnUnloadedIos; + } + + MauiThemeHelper.Instance.ActualThemeChanged += OnThemeChangedIos; + modernPicker.Unloaded += OnUnloadedIos; #else modernPicker.BackgroundColor = modernPicker.IsTransparent ? Colors.Transparent diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/PopupExtensions.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/PopupExtensions.cs new file mode 100644 index 000000000..720b20a92 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/PopupExtensions.cs @@ -0,0 +1,189 @@ +using CommunityToolkit.Maui.Views; +using SecureFolderFS.Maui.Helpers; +using SecureFolderFS.UI.Enums; + +namespace SecureFolderFS.Maui.Extensions; + +/// +/// Provides extension methods for displaying popups as overlays on top of the current page. +/// +internal static class PopupExtensions +{ + private const uint FADE_ANIMATION_MS = 300; + private const double OVERLAY_OPACITY = 0.6d; + + /// + /// Stores the close action for each popup that is currently shown as an overlay. + /// + private static readonly Dictionary> _closeActions = new(); + + /// + /// Shows a popup as an overlay on top of the current page content with a fade animation. + /// + /// The page to show the popup on. + /// The popup to display. + /// If true, tapping the background will dismiss the popup. + /// A task that completes when the popup is closed. + public static async Task OverlayPopupAsync(this Page page, Popup popup, bool dismissOnBackgroundTap = true) + { + if (page is not ContentPage contentPage) + return; + + var completionSource = new TaskCompletionSource(); + var originalContent = contentPage.Content; + if (originalContent is null || popup.Content is null) + { + completionSource.SetResult(); + await completionSource.Task; + return; + } + + // If the root is already a Grid, inject directly into it to avoid reparenting + // the existing content (which would reset view state like TransferControl visibility). + // Otherwise, wrap in a new Grid. + Grid rootGrid; + bool injectedIntoExisting; + + if (originalContent is Grid existingGrid) + { + rootGrid = existingGrid; + injectedIntoExisting = true; + } + else + { + rootGrid = new Grid(); + contentPage.Content = null; + rootGrid.Children.Add(originalContent); + contentPage.Content = rootGrid; + injectedIntoExisting = false; + } + + // Create the dimming background + var dimBackground = new BoxView() + { + Color = MauiThemeHelper.Instance.ActualTheme == ThemeType.Light ? Colors.Black : Colors.DimGray, + Opacity = 0, + InputTransparent = false + }; + // Span all rows/columns in case rootGrid has row/column definitions + Grid.SetRowSpan(dimBackground, 99); + Grid.SetColumnSpan(dimBackground, 99); + + // Create popup container + var popupContainer = new Grid() + { + HorizontalOptions = LayoutOptions.Fill, + VerticalOptions = LayoutOptions.Center, + InputTransparent = false, + CascadeInputTransparent = false, + Opacity = 0d, + Scale = 0.8d + }; + Grid.SetRowSpan(popupContainer, 99); + Grid.SetColumnSpan(popupContainer, 99); + + popup.HorizontalOptions = LayoutOptions.Fill; + popup.VerticalOptions = LayoutOptions.Center; + popup.CascadeInputTransparent = false; + popup.InputTransparent = false; + popup.Content.InputTransparent = false; + popupContainer.Children.Add(popup); + + rootGrid.Children.Add(dimBackground); + rootGrid.Children.Add(popupContainer); + + var isClosing = false; + TapGestureRecognizer? tapGesture = null; + EventHandler? tappedHandler = null; + var originalBackButtonBehavior = Shell.GetBackButtonBehavior(contentPage); + + async Task CloseOverlayAsync() + { + if (isClosing) + return; + + isClosing = true; + _closeActions.Remove(popup); + + if (tapGesture is not null) + { + if (tappedHandler is not null) + tapGesture.Tapped -= tappedHandler; + dimBackground.GestureRecognizers.Remove(tapGesture); + } + + Shell.SetBackButtonBehavior(contentPage, originalBackButtonBehavior); + + await Task.WhenAll( + dimBackground.FadeToAsync(0, FADE_ANIMATION_MS, Easing.CubicOut), + popupContainer.FadeToAsync(0, FADE_ANIMATION_MS, Easing.CubicOut), + popupContainer.ScaleToAsync(0.8, FADE_ANIMATION_MS, Easing.CubicOut) + ); + + // Only remove the overlay layers - never touch the original content + rootGrid.Children.Remove(dimBackground); + rootGrid.Children.Remove(popupContainer); + + // If we wrapped in a new grid, unwrap + if (!injectedIntoExisting) + { + contentPage.Content = null; + rootGrid.Children.Remove(originalContent); + contentPage.Content = originalContent; + } + + completionSource.TrySetResult(); + } + + _closeActions[popup] = CloseOverlayAsync; + if (dismissOnBackgroundTap) + { + tapGesture = new TapGestureRecognizer(); + tappedHandler = (_, _) => _ = CloseOverlayAsync(); + tapGesture.Tapped += tappedHandler; + dimBackground.GestureRecognizers.Add(tapGesture); + } + + Shell.SetBackButtonBehavior(contentPage, new BackButtonBehavior() + { + Command = new Command(() => _ = CloseOverlayAsync()) + }); + + await Task.Delay(200); + _ = Task.WhenAll( + dimBackground.FadeToAsync(OVERLAY_OPACITY, FADE_ANIMATION_MS, Easing.CubicInOut), + popupContainer.FadeToAsync(1, FADE_ANIMATION_MS, Easing.CubicInOut), + popupContainer.ScaleToAsync(1, FADE_ANIMATION_MS, Easing.CubicInOut) + ); + + await completionSource.Task; + } + + /// + /// Shows a popup as an overlay on the current Shell page with a fade animation. + /// + /// The popup to display. + /// If true, tapping the background will dismiss the popup. + /// A task that completes when the popup is closed. + public static Task OverlayPopupAsync(this Popup popup, bool dismissOnBackgroundTap = true) + { + var currentPage = Shell.Current?.CurrentPage; + if (currentPage is not ContentPage contentPage) + return Task.CompletedTask; + + return contentPage.OverlayPopupAsync(popup, dismissOnBackgroundTap); + } + + /// + /// Closes a popup that was shown using . + /// + /// The popup to close. + /// A task that completes when the popup has been closed. + public static Task CloseOverlayAsync(this Popup popup) + { + if (_closeActions.TryGetValue(popup, out var closeAction)) + return closeAction(); + + return Task.CompletedTask; + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/SizingExtensions.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/SizingExtensions.cs new file mode 100644 index 000000000..b8a658f72 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/SizingExtensions.cs @@ -0,0 +1,20 @@ +namespace SecureFolderFS.Maui.Extensions +{ + internal static class SizingExtensions + { + public static Rect GetBoundsRelativeTo(this VisualElement view, VisualElement relativeTo) + { + var bounds = view.Bounds; + var parent = view.Parent as VisualElement; + + while (parent != null && parent != relativeTo) + { + bounds.X += parent.X; + bounds.Y += parent.Y; + parent = parent.Parent as VisualElement; + } + + return bounds; + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Helpers/DeferredInitialization.cs b/src/Platforms/SecureFolderFS.Maui/Helpers/DeferredInitialization.cs index 9c6ea0cbd..5db8e887d 100644 --- a/src/Platforms/SecureFolderFS.Maui/Helpers/DeferredInitialization.cs +++ b/src/Platforms/SecureFolderFS.Maui/Helpers/DeferredInitialization.cs @@ -1,3 +1,4 @@ +using System.Threading.Channels; using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Maui.Helpers @@ -6,14 +7,16 @@ internal sealed class DeferredInitialization : IDisposable where T : notnull { private T? _context; - private bool _isProcessing; private readonly int _maxParallelization; - private readonly List _initializations = new(); + private readonly Channel _channel; + private Task? _processingTask; private readonly SemaphoreSlim _semaphore = new(1, 1); public DeferredInitialization(int maxParallelization) { _maxParallelization = maxParallelization; + _channel = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, AllowSynchronousContinuations = false }); } public void SetContext(T context) @@ -22,8 +25,9 @@ public void SetContext(T context) return; _context = context; - lock (_initializations) - _initializations.Clear(); + + // Drain the channel without blocking + while (_channel.Reader.TryRead(out _)) { } } public void Enqueue(IAsyncInitialize asyncInitialize) @@ -31,56 +35,50 @@ public void Enqueue(IAsyncInitialize asyncInitialize) if (_context is null) return; - lock (_initializations) - _initializations.Add(asyncInitialize); - - _ = StartProcessingAsync(); + _channel.Writer.TryWrite(asyncInitialize); + EnsureProcessing(); } - private async Task StartProcessingAsync() + private void EnsureProcessing() { - await _semaphore.WaitAsync(); + if (_processingTask is { IsCompleted: false }) + return; + _semaphore.Wait(); try { - if (_isProcessing) + if (_processingTask is { IsCompleted: false }) return; - _isProcessing = true; + _processingTask = Task.Run(ProcessLoopAsync); } finally { _semaphore.Release(); } + } - try + private async Task ProcessLoopAsync() + { + var batch = new List(_maxParallelization); + + while (await _channel.Reader.WaitToReadAsync()) { - while (true) - { - IAsyncInitialize[] batch; - lock (_initializations) - { - if (_initializations.Count == 0) - break; + batch.Clear(); + while (batch.Count < _maxParallelization && _channel.Reader.TryRead(out var item)) + batch.Add(item); - batch = _initializations.Take(_maxParallelization).ToArray(); - _initializations.RemoveRange(0, batch.Length); - } + if (batch.Count == 0) + continue; - var tasks = batch.Select(init => init.InitAsync()); - await Task.Run(async () => await Task.WhenAll(tasks)); - } - } - finally - { - _isProcessing = false; + await Task.WhenAll(batch.Select(init => init.InitAsync())); } } /// public void Dispose() { - _initializations.Clear(); + _channel.Writer.TryComplete(); _semaphore.Dispose(); } } diff --git a/src/Platforms/SecureFolderFS.Maui/Helpers/EasingHelpers.cs b/src/Platforms/SecureFolderFS.Maui/Helpers/EasingHelpers.cs index 2e553033a..1cd0604a3 100644 --- a/src/Platforms/SecureFolderFS.Maui/Helpers/EasingHelpers.cs +++ b/src/Platforms/SecureFolderFS.Maui/Helpers/EasingHelpers.cs @@ -3,15 +3,15 @@ namespace SecureFolderFS.Maui.Helpers internal static class EasingHelpers { public static readonly Easing QuarticIn = new(x => Math.Pow(x, 4)); - + public static readonly Easing QuarticOut = new(x => 1 - Math.Pow(1 - x, 4)); - + public static readonly Easing QuarticInOut = new(x => x < 0.5 ? 8 * Math.Pow(x, 4) : 1 - Math.Pow(-2 * x + 2, 4) / 2); - + public static readonly Easing CubicBezierOut = CubicBezier(0.25, 0.46, 0.45, 0.94); public static readonly Easing EaseOutExpo = CubicBezier(0.16, 1, 0.3, 1); - + private static Easing CubicBezier(double x1, double y1, double x2, double y2) { return new Easing(t => diff --git a/src/Platforms/SecureFolderFS.Maui/Helpers/MauiThemeHelper.cs b/src/Platforms/SecureFolderFS.Maui/Helpers/MauiThemeHelper.cs index 8ad3355f2..ad3255323 100644 --- a/src/Platforms/SecureFolderFS.Maui/Helpers/MauiThemeHelper.cs +++ b/src/Platforms/SecureFolderFS.Maui/Helpers/MauiThemeHelper.cs @@ -1,3 +1,4 @@ +using SecureFolderFS.UI.Enums; using SecureFolderFS.UI.Helpers; namespace SecureFolderFS.Maui.Helpers @@ -5,12 +6,21 @@ namespace SecureFolderFS.Maui.Helpers /// internal sealed class MauiThemeHelper : ThemeHelper { - private static MauiThemeHelper? _Instance; - /// /// Gets the singleton instance of . /// - public static MauiThemeHelper Instance => _Instance ??= new(); + public static MauiThemeHelper Instance => field ??= new(); + + /// + public override event EventHandler? ActualThemeChanged; + + /// + public override ThemeType ActualTheme => (ThemeType)Application.Current!.RequestedTheme; + + private MauiThemeHelper() + { + Application.Current!.RequestedThemeChanged += Application_RequestedThemeChanged; + } /// protected override void UpdateTheme() @@ -18,7 +28,13 @@ protected override void UpdateTheme() MainThread.BeginInvokeOnMainThread(() => { App.Instance.UserAppTheme = (AppTheme)(int)CurrentTheme; + ActualThemeChanged?.Invoke(this, EventArgs.Empty); }); } + + private void Application_RequestedThemeChanged(object? sender, AppThemeChangedEventArgs e) + { + ActualThemeChanged?.Invoke(this, EventArgs.Empty); + } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs b/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs new file mode 100644 index 000000000..8352e62dc --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs @@ -0,0 +1,87 @@ +using SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser; + +namespace SecureFolderFS.Maui.Helpers +{ + /// + /// Manages swipe-to-select gesture state, mirroring the iOS Photos/Gallery app behavior. + /// + internal sealed class SwipeSelectionManager + { + private bool _isActive; + private bool _selectionIntent; // true = select, false = deselect + private readonly HashSet _processedItemIds = new(); + private readonly HashSet _currentlyInsideIds = new(); + + /// Whether a swipe gesture is currently in progress. + public bool IsActive => _isActive; + + /// + /// Call when a pan gesture BEGINS on a specific item. + /// Determines the intent from the item's current selection state. + /// + /// The item under the finger when the gesture started. + public void Begin(BrowserItemViewModel item) + { + _isActive = true; + _selectionIntent = !item.IsSelected; // flip from the current state + _processedItemIds.Clear(); + Apply(item); + } + + public void UpdateFromRectangle(IList allItems, Func isInsideRect) + { + if (!_isActive) + return; + + foreach (var item in allItems) + { + var inside = isInsideRect(item); + var id = item.Inner.Id; + + if (inside && !_currentlyInsideIds.Contains(id)) + { + // Entered rectangle — apply intent + item.IsSelected = _selectionIntent; + _currentlyInsideIds.Add(id); + } + else if (!inside && _currentlyInsideIds.Contains(id)) + { + // Left rectangle — revert to original state + item.IsSelected = !_selectionIntent; + _currentlyInsideIds.Remove(id); + } + } + } + + /// + /// Call whenever the swipe moves over a new item. + /// Applies the selection intent if the item hasn't been processed yet. + /// + /// The item currently under the finger. + public void Update(BrowserItemViewModel item) + { + if (!_isActive) + return; + + Apply(item); + } + + /// Call when the pan gesture ends or is cancelled. + public void End() + { + _isActive = false; + _processedItemIds.Clear(); + _currentlyInsideIds.Clear(); + } + + private void Apply(BrowserItemViewModel item) + { + var id = item.Inner.Id; + if (_processedItemIds.Contains(id)) + return; + + item.IsSelected = _selectionIntent; + _processedItemIds.Add(id); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Localization/ResourceString.cs b/src/Platforms/SecureFolderFS.Maui/Localization/ResourceString.cs index 5a71d4423..1c2a29180 100644 --- a/src/Platforms/SecureFolderFS.Maui/Localization/ResourceString.cs +++ b/src/Platforms/SecureFolderFS.Maui/Localization/ResourceString.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Globalization; using System.Runtime.CompilerServices; +using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared; @@ -43,7 +44,7 @@ private string GetLocalized() if (LocalizationService is null) return $"{{{Rid}}}"; - return LocalizationService.GetResource(Rid ?? string.Empty) ?? $"{{{Rid}}}"; + return (Rid ?? string.Empty).ToLocalized(); } } } diff --git a/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs b/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs index 64e653fe8..4537fb2fd 100644 --- a/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs +++ b/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs @@ -2,6 +2,8 @@ using CommunityToolkit.Maui; using LibVLCSharp.MAUI; using Plugin.Maui.BottomSheet.Hosting; +using Plugin.SegmentedControl.Maui; +using SkiaSharp.Views.Maui.Controls.Hosting; using Xe.AcrylicView; #if ANDROID using MauiIcons.Material; @@ -31,11 +33,13 @@ public static MauiApp CreateMauiApp() .ConfigureContextMenuContainer() // https://github.com/anpin/ContextMenuContainer .UseLibVLCSharp() // https://github.com/videolan/libvlcsharp .UseAcrylicView() // https://github.com/sswi/AcrylicView.MAUI + .UseSegmentedControl() // https://github.com/thomasgalliker/Plugin.SegmentedControl.Maui + .UseSkiaSharp() // https://github.com/mono/SkiaSharp #if ANDROID .UseMaterialMauiIcons() // https://github.com/AathifMahir/MauiIcons #elif IOS - .UseCupertinoMauiIcons() + .UseCupertinoMauiIcons() // https://github.com/AathifMahir/MauiIcons #endif // Handlers diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml index 444981d92..1dff33d81 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AndroidManifest.xml @@ -21,5 +21,6 @@ + diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs index 18cdead82..e5fa9edc6 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs @@ -40,6 +40,7 @@ protected override IServiceCollection ConfigureServices(IModifiableFolder settin return base.ConfigureServices(settingsFolder) //.Override(AddService.AddSingleton) .Override(AddService.AddSingleton) + .Override(AddService.AddSingleton) .Override(AddService.AddSingleton) .Override(AddService.AddSingleton) .Override(AddService.AddSingleton) diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/DragThresholdTouchListener.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/DragThresholdTouchListener.cs new file mode 100644 index 000000000..d8cae8863 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/DragThresholdTouchListener.cs @@ -0,0 +1,136 @@ +using Android.Content; +using Android.Views; +using SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser; +using View = Android.Views.View; + +namespace SecureFolderFS.Maui.Platforms.Android.Helpers +{ + /// + /// An Android touch handler that implements drag-and-drop with a distance threshold. + /// This allows the context menu (long-press via ContextMenuContainer) to work + /// properly on Android by only starting a drag when the finger moves beyond the system's + /// touch slop threshold, rather than competing with the long-click handler. + /// + /// + /// Uses the C# event (additive) instead of + /// (replacing) so that MAUI's internal gesture + /// handling (tap, long-press, etc.) is not disrupted. + /// + internal sealed class DragThresholdTouchHandler + { + /// + /// Stores the currently dragged item view model so that drop handlers can retrieve it. + /// This is set when a drag starts and cleared when the drag ends. + /// + internal static BrowserItemViewModel? CurrentDraggedItem { get; set; } + + /// + /// The MIME type used for internal drag-and-drop operations. + /// + internal const string DragMimeType = "application/x-sffs-drag-item"; + + private readonly Microsoft.Maui.Controls.View _mauiView; + private float _startX; + private float _startY; + private int _touchSlop; + private bool _isDragStarted; + private bool _isTracking; + + public DragThresholdTouchHandler(Microsoft.Maui.Controls.View mauiView) + { + _mauiView = mauiView; + } + + /// + /// Attaches to the given Android view's event. + /// + public void Attach(View androidView) + { + androidView.Touch += OnTouch; + } + + private void OnTouch(object? sender, View.TouchEventArgs e) + { + var v = sender as View; + var motionEvent = e.Event; + + if (v is null || motionEvent is null) + { + e.Handled = false; + return; + } + + switch (motionEvent.ActionMasked) + { + case MotionEventActions.Down: + _startX = motionEvent.RawX; + _startY = motionEvent.RawY; + _isDragStarted = false; + _isTracking = true; + _touchSlop = v.Context is not null ? ViewConfiguration.Get(v.Context)?.ScaledTouchSlop ?? 24 : 24; + e.Handled = false; + break; + + case MotionEventActions.Move: + if (!_isTracking || _isDragStarted) + { + e.Handled = false; + break; + } + + var deltaX = motionEvent.RawX - _startX; + var deltaY = motionEvent.RawY - _startY; + var distance = Math.Sqrt(deltaX * deltaX + deltaY * deltaY); + + // Only start drag after exceeding the touch slop threshold + if (distance > _touchSlop) + { + _isDragStarted = true; + _isTracking = false; + StartNativeDrag(v); + e.Handled = true; // Consume the event to prevent further touch processing + } + else + { + e.Handled = false; + } + break; + + case MotionEventActions.Up: + case MotionEventActions.Cancel: + _isDragStarted = false; + _isTracking = false; + e.Handled = false; + break; + + default: + e.Handled = false; + break; + } + } + + /// + /// Starts a native Android drag-and-drop operation, bypassing MAUI's + /// entirely. The dragged item is stored in so that + /// MAUI drop handlers can retrieve it. + /// + private void StartNativeDrag(View androidView) + { + if (_mauiView.BindingContext is not BrowserItemViewModel itemViewModel) + return; + + // Store the dragged item so drop handlers can access it + CurrentDraggedItem = itemViewModel; + + // Create a ClipData with our custom MIME type + var clipData = ClipData.NewPlainText(DragMimeType, itemViewModel.Inner.Id); + + // Create a drag shadow from the view + var shadowBuilder = new View.DragShadowBuilder(androidView); + + // Start the native drag + androidView.StartDragAndDrop(clipData, shadowBuilder, null, (int)DragFlags.Global); + } + } +} + diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs index 84b675fef..51fdf996b 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/MainActivity.cs @@ -3,7 +3,9 @@ using Android.Content; using Android.Content.PM; using Android.OS; +using Android.Views; using AndroidX.Activity; +using AndroidX.Core.View; using Microsoft.Maui.Platform; using SecureFolderFS.Maui.Helpers; using SecureFolderFS.UI.Enums; @@ -14,6 +16,7 @@ namespace SecureFolderFS.Maui Theme = "@style/Maui.SplashTheme", Exported = true, MainLauncher = true, + WindowSoftInputMode = SoftInput.AdjustResize, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density, ResizeableActivity = true, LaunchMode = LaunchMode.SingleTask)] @@ -36,6 +39,19 @@ protected override void OnCreate(Bundle? savedInstanceState) // Enable edge to edge EdgeToEdge.Enable(this); + // Apply system bar insets to the root view so the Shell toolbar + // is not hidden behind the status bar on navigated pages. + var rootView = FindViewById(Android.Resource.Id.Content); + if (rootView is not null) + { + ViewCompat.SetOnApplyWindowInsetsListener(rootView, new WindowInsetsListener()); + } + + // Make the navigation bar transparent so content draws behind it +#pragma warning disable CA1422 + Window?.SetNavigationBarColor(Android.Graphics.Color.Transparent); +#pragma warning restore CA1422 + // Configure StatusBar color ApplyStatusBarColor(MauiThemeHelper.Instance.CurrentTheme); @@ -68,5 +84,32 @@ private void ApplyStatusBarColor(ThemeType themeType) }] as Color)!.ToPlatform()); #pragma warning restore CA1422 } + + private sealed class WindowInsetsListener : Java.Lang.Object, IOnApplyWindowInsetsListener + { + public WindowInsetsCompat? OnApplyWindowInsets(Android.Views.View? view, WindowInsetsCompat? insets) + { + if (view is null || insets is null) + return insets; + + // Only apply top (status bar) padding so the toolbar is visible. + // Bottom padding is NOT applied so content extends behind the + // navigation bar for a modern edge-to-edge experience. + var statusBarInsets = insets.GetInsets(WindowInsetsCompat.Type.StatusBars()); + var imeInsets = insets.GetInsets(WindowInsetsCompat.Type.Ime()); + + // When the soft keyboard (IME) is visible, use its bottom inset + // so that input fields are not hidden behind the keyboard. + var bottomPadding = imeInsets is not null && imeInsets.Bottom > 0 ? imeInsets.Bottom : 0; + + view.SetPadding( + statusBarInsets?.Left ?? 0, + statusBarInsets?.Top ?? 0, + statusBarInsets?.Right ?? 0, + bottomPadding); + + return WindowInsetsCompat.Consumed; + } + } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs index 63cc85c5b..2da5fd153 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidFileExplorerService.cs @@ -5,9 +5,13 @@ using CommunityToolkit.Maui.Core.Extensions; using CommunityToolkit.Maui.Storage; using OwlCore.Storage; +using SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem; using SecureFolderFS.Maui.Platforms.Android.Storage; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.Pickers; +using SecureFolderFS.Storage.VirtualFileSystem; using AndroidUri = Android.Net.Uri; using AOSEnvironment = Android.OS.Environment; @@ -62,29 +66,53 @@ internal sealed class AndroidFileExplorerService : IFileExplorerService } /// - public Task TryOpenInFileExplorerAsync(IFolder folder, CancellationToken cancellationToken = default) + public Task TryOpenInFileExplorerAsync(IFolder folder, CancellationToken cancellationToken = default) { try { var context = MainActivity.Instance; if (context is null) - return Task.CompletedTask; + return Task.FromResult(false); + + // Find the SafRoot that owns this folder by matching VFSRoot + IVfsRoot? vfsRoot = null; + if (folder is IWrapper wrapper) + { + // Walk wrappers to find the CryptoStorable which holds FileSystemSpecifics -> IVFSRoot + // Alternatively, match via FileSystemManager directly + vfsRoot = FileSystemManager.Instance.FileSystems + .FirstOrDefault(x => (x.PlaintextRoot as IWrapper)?.GetDeepestWrapper().Inner.Id == wrapper.Inner.Id || IsOwnedByRoot(wrapper.Inner, x)); + } + + if (vfsRoot is null) + return Task.FromResult(false); + + // Now find the corresponding SafRoot for this IVFSRoot + var safRoot = RootCollection.Instance?.Roots.FirstOrDefault(r => r.StorageRoot == vfsRoot); + if (safRoot is null) + return Task.FromResult(false); var intent = new Intent(Intent.ActionView); - intent.SetType("*/*"); - intent.AddCategory(Intent.CategoryDefault); - intent.AddFlags(ActivityFlags.NewTask); + var documentUri = AndroidUri.Parse($"content://{Core.MobileFS.Constants.Android.FileSystem.AUTHORITY}/root/{safRoot.RootId}"); + intent.SetData(documentUri); + intent.AddFlags(ActivityFlags.NewTask | ActivityFlags.GrantReadUriPermission); context.StartActivity(intent); - return Task.CompletedTask; + return Task.FromResult(true); } - catch (Exception ex) + catch (Exception) { - _ = ex; - return Task.CompletedTask; + return Task.FromResult(false); } } + private static bool IsOwnedByRoot(IFolder folder, IVfsRoot root) + { + var virtualizedRootInner = (root.PlaintextRoot as IWrapper)?.GetDeepestWrapper().Inner; + return folder.Id == virtualizedRootInner?.Id + || folder.Id.StartsWith(virtualizedRootInner?.Id ?? string.Empty, StringComparison.Ordinal); + } + /// public async Task SaveFileAsync(string suggestedName, Stream dataStream, IDictionary? filter, CancellationToken cancellationToken = default) { diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidShareService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidShareService.cs new file mode 100644 index 000000000..46ed2762d --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidShareService.cs @@ -0,0 +1,56 @@ +using Android.Content; +using Android.OS; +using OwlCore.Storage; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.Helpers; +using Application = Android.App.Application; +using Uri = Android.Net.Uri; + +namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation +{ + /// + internal sealed class AndroidShareService : IShareService + { + /// + public async Task ShareTextAsync(string text, string title) + { + var intent = new Intent(Intent.ActionSend); + intent.SetType("text/plain"); + intent.PutExtra(Intent.ExtraText, text); + intent.PutExtra(Intent.ExtraSubject, title); + + var chooserIntent = Intent.CreateChooser(intent, title); + chooserIntent?.AddFlags(ActivityFlags.NewTask); + + Application.Context.StartActivity(chooserIntent); + await Task.CompletedTask; + } + + /// + public async Task ShareFileAsync(IFile file) + { + // Register the file with the ShareContentProvider + var fileId = ShareContentProvider.RegisterFile(file); + + // Build the content URI for this file + var authority = $"{Application.Context.PackageName}.shareProvider"; + var contentUri = Uri.Parse($"content://{authority}/{fileId}/{file.Name}"); + + // Determine MIME type + var mimeType = FileTypeHelper.GetMimeType(file.Name); + + // Create share intent + var intent = new Intent(Intent.ActionSend); + intent.SetType(mimeType); + intent.PutExtra(Intent.ExtraStream, contentUri); + intent.AddFlags(ActivityFlags.GrantReadUriPermission); + + var chooserIntent = Intent.CreateChooser(intent, file.Name); + chooserIntent?.AddFlags(ActivityFlags.NewTask); + + Application.Context.StartActivity(chooserIntent); + await Task.CompletedTask; + } + } +} + diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs index 4cebf3485..f35b22f98 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs @@ -23,10 +23,7 @@ public override IEnumerable GetContentCiphers() // - https://nsec.rocks/docs/install#supported-platforms yield return Constants.CipherId.AES_GCM; - -#if DEBUG yield return Constants.CipherId.NONE; -#endif } /// diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultFileSystemService.cs index 85572c0cc..f7d92def5 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultFileSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultFileSystemService.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; using MauiIcons.Core; -using MauiIcons.Cupertino; +using MauiIcons.Material; using SecureFolderFS.Core.MobileFS.Platforms.Android; using SecureFolderFS.Maui.AppModels; using SecureFolderFS.Sdk.Enums; @@ -10,8 +10,9 @@ using SecureFolderFS.Sdk.ViewModels.Views.Wizard; using SecureFolderFS.Sdk.ViewModels.Views.Wizard.DataSources; using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.VirtualFileSystem; using SecureFolderFS.UI.ServiceImplementation; -using IFileSystem = SecureFolderFS.Storage.VirtualFileSystem.IFileSystem; using static SecureFolderFS.Sdk.Constants.DataSources; using static SecureFolderFS.Sdk.Ftp.Constants; @@ -21,7 +22,7 @@ namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation internal sealed class AndroidVaultFileSystemService : BaseVaultFileSystemService { /// - public override async IAsyncEnumerable GetFileSystemsAsync([EnumeratorCancellation] CancellationToken cancellationToken) + public override async IAsyncEnumerable GetFileSystemsAsync([EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; yield return new AndroidFileSystem(); @@ -33,27 +34,27 @@ public override async IAsyncEnumerable GetSources var fileExplorerService = DI.Service(); yield return new PickerSourceWizardViewModel(DATA_SOURCE_PICKER, fileExplorerService, mode, vaultCollectionModel) { - Icon = new ImageIcon(new MauiIcon() { Icon = CupertinoIcons.Tray2 }) + Icon = new ImageIcon(new MauiIcon() { Icon = MaterialIcons.Storage, IconColor = Colors.White }) }; yield return new AccountSourceWizardViewModel(DATA_SOURCE_FTP, "FTP".ToLocalized(), mode, vaultCollectionModel) { - Icon = new ImageResourceFile("source_network_drive_macos.png", false) + Icon = new ImageResource("source_network_drive_ftp.png") }; - - yield return new AccountSourceWizardViewModel($"{nameof(SecureFolderFS)}.GoogleDrive", "GoogleDrive".ToLocalized(), mode, vaultCollectionModel) + + yield return new AccountSourceWizardViewModel($"{nameof(SecureFolderFS)}.WebDavClient", "WebDavClient".ToLocalized(), mode, vaultCollectionModel) { - Icon = new ImageResourceFile("source_gdrive.png", false) + Icon = new ImageResource("source_webdav.png") }; - - yield return new AccountSourceWizardViewModel($"{nameof(SecureFolderFS)}.OneDrive", "OneDrive".ToLocalized(), mode, vaultCollectionModel) + + yield return new AccountSourceWizardViewModel($"{nameof(SecureFolderFS)}.GoogleDrive", "GoogleDrive".ToLocalized(), mode, vaultCollectionModel) { - Icon = new ImageResourceFile("source_onedrive.png", false) + Icon = new ImageResource("source_gdrive.png") }; - yield return new AccountSourceWizardViewModel($"{nameof(SecureFolderFS)}.AmazonS3", "AmazonS3".ToLocalized(), mode, vaultCollectionModel) + yield return new AccountSourceWizardViewModel($"{nameof(SecureFolderFS)}.Dropbox", "Dropbox".ToLocalized(), mode, vaultCollectionModel) { - Icon = new ImageResourceFile("source_aws_s3.png", false) + Icon = new ImageResource("source_dropbox.png") }; await Task.CompletedTask; diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs new file mode 100644 index 000000000..20057fb10 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs @@ -0,0 +1,187 @@ +using System.Diagnostics.CodeAnalysis; +using Android.Content; +using Android.Database; +using Android.OS; +using Android.Provider; +using Android.Runtime; +using OwlCore.Storage; +using SecureFolderFS.Shared.Helpers; +using Uri = Android.Net.Uri; + +namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation +{ + /// + /// A ContentProvider that serves virtualized files for sharing without creating temporary files. + /// Files are streamed directly from their IFile implementation through a pipe. + /// + [ContentProvider(["${applicationId}.shareProvider"], + Name = "securefolderfs.shareProvider", + Enabled = true, + Exported = false, + GrantUriPermissions = true)] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] + [Preserve(AllMembers = true)] + public class ShareContentProvider : ContentProvider + { + private static readonly Dictionary _registeredFiles = new(); + private static readonly Lock _lock = new(); + + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ShareContentProvider))] + public ShareContentProvider() + { + } + + /// + /// Registers a file for sharing and returns a unique identifier. + /// + /// The file to register. + /// A unique identifier for the file. + public static string RegisterFile(IFile file) + { + var fileId = Guid.NewGuid().ToString("N"); + lock (_lock) + { + _registeredFiles[fileId] = file; + } + + return fileId; + } + + /// + /// Unregisters a file after sharing is complete. + /// + /// The file identifier to unregister. + public static void UnregisterFile(string fileId) + { + lock (_lock) + { + _registeredFiles.Remove(fileId); + } + } + + /// + [Register("onCreate", "()Z", "GetOnCreateHandler")] + public override bool OnCreate() + { + return true; + } + + /// + public override ParcelFileDescriptor? OpenFile(Uri uri, string mode) + { + var fileId = uri.PathSegments?.FirstOrDefault(); + if (fileId is null) + return null; + + IFile? file; + lock (_lock) + { + if (!_registeredFiles.TryGetValue(fileId, out file)) + return null; + } + + // Create a pipe and stream the file content through it + var pipe = ParcelFileDescriptor.CreatePipe(); + if (pipe is null || pipe.Length < 2) + return null; + + var readSide = pipe[0]; + var writeSide = pipe[1]; + + // Stream the content in a background thread + _ = Task.Run(async () => + { + try + { + await using var fileStream = await file.OpenReadAsync(); + using var outputStream = new ParcelFileDescriptor.AutoCloseOutputStream(writeSide); + + int bytesRead; + var buffer = new byte[8192]; + while ((bytesRead = await fileStream.ReadAsync(buffer)) > 0) + await outputStream.WriteAsync(buffer, 0, bytesRead); + } + catch + { + // Silently handle errors during streaming + } + finally + { + // Clean up the registration after streaming + UnregisterFile(fileId); + } + }); + + return readSide; + } + + /// + public override ICursor? Query(Uri uri, string[]? projection, string? selection, string[]? selectionArgs, string? sortOrder) + { + var fileId = uri.PathSegments?.FirstOrDefault(); + if (fileId is null) + return null; + + IFile? file; + lock (_lock) + { + if (!_registeredFiles.TryGetValue(fileId, out file)) + return null; + } + + // Get the filename from the URI path (second segment) + var fileName = uri.PathSegments?.ElementAtOrDefault(1) ?? file.Name; + + var columns = projection ?? [ IOpenableColumns.DisplayName, IOpenableColumns.Size ]; + var cursor = new MatrixCursor(columns); + var row = cursor.NewRow(); + + foreach (var column in columns) + { + switch (column) + { + case IOpenableColumns.DisplayName: + row?.Add(fileName); + break; + + case IOpenableColumns.Size: + row?.Add(null); // Size unknown for streams + break; + + default: + row?.Add(null); + break; + } + } + + return cursor; + } + + /// + public override string? GetType(Uri uri) + { + var fileId = uri.PathSegments?.FirstOrDefault(); + if (fileId is null) + return "application/octet-stream"; + + IFile? file; + lock (_lock) + { + if (!_registeredFiles.TryGetValue(fileId, out file)) + return "application/octet-stream"; + } + + return FileTypeHelper.GetMimeType(file.Name); + } + + /// + public override Uri? Insert(Uri uri, ContentValues? values) => null; + + /// + public override int Delete(Uri uri, string? selection, string[]? selectionArgs) => 0; + + /// + public override int Update(Uri uri, ContentValues? values, string? selection, string[]? selectionArgs) => 0; + } +} + diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs index a5888f02e..405c13d1f 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs @@ -12,13 +12,19 @@ namespace SecureFolderFS.Maui.Platforms.Android.Storage { /// - internal sealed class AndroidFile : AndroidStorable, IChildFile + internal sealed class AndroidFile : AndroidStorable, IChildFile, ILastModifiedAt, ISizeOf { /// public override string Name { get; } /// public override DocumentFile? Document { get; } + + /// + public ILastModifiedAtProperty LastModifiedAt => field ??= new AndroidLastModifiedAtProperty(Id, Document ?? throw new ArgumentNullException(nameof(Document))); + + /// + public ISizeOfProperty SizeOf => field ??= new AndroidSizeOfProperty(Id, Document ?? throw new ArgumentNullException(nameof(Document))); public AndroidFile(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null) : base(uri, activity, parent, permissionRoot, bookmarkId) @@ -50,28 +56,19 @@ public Task OpenStreamAsync(FileAccess accessMode, CancellationToken can else { var fd = activity.ContentResolver?.OpenFileDescriptor(Inner, "rwt"); + if (fd is null) + return Task.FromException(new UnauthorizedAccessException("Could not open file descriptor.")); + var fInChannel = new FileInputStream(fd.FileDescriptor).Channel; var fOutChannel = new FileOutputStream(fd.FileDescriptor).Channel; - if (fInChannel is null || fOutChannel is null) - return Task.FromException( - new ArgumentException("Could not open input and output streams.")); + return Task.FromException(new ArgumentException("Could not open input and output streams.")); - var channelledStream = new ChannelledStream(fInChannel, fOutChannel); + var channelledStream = new ChannelledStream(fInChannel, fOutChannel, fd); return Task.FromResult(channelledStream); } } - /// - public override Task GetPropertiesAsync() - { - if (Document is null) - return Task.FromException(new ArgumentNullException(nameof(Document))); - - properties ??= new AndroidFileProperties(Document); - return Task.FromResult(properties); - } - private static bool IsVirtualFile(Context context, AndroidUri uri) { if (!OperatingSystem.IsAndroidVersionAtLeast(24)) diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs index a2f757599..7f628a7d9 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs @@ -4,15 +4,21 @@ using AndroidX.DocumentFile.Provider; using OwlCore.Storage; using SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.Renamable; -using SecureFolderFS.Storage.StorageProperties; using Activity = Android.App.Activity; using AndroidUri = Android.Net.Uri; namespace SecureFolderFS.Maui.Platforms.Android.Storage { /// - internal sealed class AndroidFolder : AndroidStorable, IModifiableFolder, IChildFolder, IGetFirstByName, IRenamableFolder // TODO: Implement: IGetFirstByName, IGetItem + internal sealed class AndroidFolder : AndroidStorable, + IRenamableFolder, + IChildFolder, + IGetFirstByName, + ICreateRenamedCopyOf, + IMoveRenamedFrom, + ILastModifiedAt { private static Exception RenameException { get; } = new IOException("Could not rename the item."); @@ -21,6 +27,9 @@ internal sealed class AndroidFolder : AndroidStorable, IModifiableFolder, IChild /// public override DocumentFile? Document { get; } + + /// + public ILastModifiedAtProperty LastModifiedAt => field ??= new AndroidLastModifiedAtProperty(Id, Document ?? throw new ArgumentNullException(nameof(Document))); public AndroidFolder(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null) : base(uri, activity, parent, permissionRoot, bookmarkId) @@ -43,7 +52,7 @@ public Task RenameAsync(IStorableChild storable, string newName, if (uri is null) return Task.FromException(RenameException); - return Task.FromResult(new AndroidFolder(uri, activity, parent, permissionRoot)); + return Task.FromResult(new AndroidFolder(uri, activity, Parent, permissionRoot)); } case AndroidFile file: @@ -55,7 +64,7 @@ public Task RenameAsync(IStorableChild storable, string newName, if (uri is null) return Task.FromException(RenameException); - return Task.FromResult(new AndroidFile(uri, activity, parent, permissionRoot)); + return Task.FromResult(new AndroidFile(uri, activity, Parent, permissionRoot)); } default: return Task.FromException(new ArgumentOutOfRangeException(nameof(storable))); @@ -68,8 +77,15 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = if (Document is null) yield break; - foreach (var item in Document.ListFiles()) + var items = Document.ListFiles(); + if (items is null) + yield break; + + foreach (var item in items) { + if (item.Uri is null) + continue; + var isDirectory = item.IsDirectory; var result = (IStorableChild?)(type switch { @@ -139,7 +155,7 @@ async Task DeleteContents(IStorableChild storable) public Task CreateFolderAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { var newFolder = Document?.CreateDirectory(name); - if (newFolder is null) + if (newFolder?.Uri is null) return Task.FromException(new UnauthorizedAccessException("Could not create Android folder.")); return Task.FromResult(new AndroidFolder(newFolder.Uri, activity, this, permissionRoot)); @@ -151,23 +167,92 @@ public Task CreateFileAsync(string name, bool overwrite = false, Can var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream"; var existingFile = Document?.FindFile(name); - if (overwrite && existingFile is not null) + if (overwrite && existingFile?.Uri is not null) + { existingFile.Delete(); - else if (existingFile is not null) + } + else if (existingFile?.Uri is not null) + { return Task.FromResult(new AndroidFile(existingFile.Uri, activity, this, permissionRoot)); + } var newFile = Document?.CreateFile(mimeType, name); - if (newFile is null) + if (newFile?.Uri is null) return Task.FromException(new UnauthorizedAccessException("Could not create Android file.")); return Task.FromResult(new AndroidFile(newFile.Uri, activity, this, permissionRoot)); } + /// + public Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite, CancellationToken cancellationToken, + CreateCopyOfDelegate fallback) + { + return CreateCopyOfAsync(fileToCopy, overwrite, fileToCopy.Name, cancellationToken, (mf, f, ov, _, ct) => fallback(mf, f, ov, ct)); + } + + /// + public Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite, string newName, CancellationToken cancellationToken, + CreateRenamedCopyOfDelegate fallback) + { + if (fileToCopy is not AndroidFile androidFile) + return fallback(this, fileToCopy, overwrite, newName, cancellationToken); + + var existingFile = Document?.FindFile(newName); + if (existingFile is not null) + { + if (!overwrite) + return Task.FromException(new FileAlreadyExistsException(newName)); + + existingFile.Delete(); + } + + // No-op if source and destination are the same + if (androidFile.Id == Path.Combine(Id, newName)) + return Task.FromResult(androidFile); + + return CopyInternalAsync(androidFile, newName, cancellationToken); + } + + /// + public Task MoveFromAsync(IChildFile fileToMove, IModifiableFolder source, bool overwrite, CancellationToken cancellationToken, + MoveFromDelegate fallback) + { + return MoveFromAsync(fileToMove, source, overwrite, fileToMove.Name, cancellationToken, (mf, f, src, ov, _, ct) => fallback(mf, f, src, ov, ct)); + } + + /// + public Task MoveFromAsync(IChildFile fileToMove, IModifiableFolder source, bool overwrite, string newName, + CancellationToken cancellationToken, MoveRenamedFromDelegate fallback) + { + if (fileToMove is not AndroidFile androidFile) + return fallback(this, fileToMove, source, overwrite, newName, cancellationToken); + + // No-op if source and destination path are identical + if (androidFile.Id == Path.Combine(Id, newName)) + return Task.FromResult(fileToMove); + + var existingFile = Document?.FindFile(newName); + if (existingFile is not null) + { + if (!overwrite) + return Task.FromException(new FileAlreadyExistsException(newName)); + + existingFile.Delete(); + } + + // Fast-path: same folder means this is a pure rename + var isSameFolder = source is AndroidFolder androidSource && androidSource.Id == Id; + if (isSameFolder) + return RenameInternalAsync(androidFile, newName); + + return MoveInternalAsync(androidFile, source, newName, cancellationToken); + } + /// public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default) { var file = Document?.FindFile(name); - if (file is not null) + if (file?.Uri is not null) { if (file.IsFile) return new AndroidFile(file.Uri, activity, this, permissionRoot); @@ -179,7 +264,7 @@ public async Task GetFirstByNameAsync(string name, CancellationT } var target = await GetItemsAsync(cancellationToken: cancellationToken) - .FirstOrDefaultAsync(x => name.Equals(x.Name, StringComparison.Ordinal), cancellationToken) + .FirstOrDefaultAsyncImpl(x => name.Equals(x.Name, StringComparison.Ordinal), cancellationToken) .ConfigureAwait(false); if (target is null) @@ -187,15 +272,64 @@ public async Task GetFirstByNameAsync(string name, CancellationT return target; } + + private async Task CopyInternalAsync(AndroidFile source, string newName, CancellationToken cancellationToken) + { + if (activity.ContentResolver is null) + throw new UnauthorizedAccessException("Could not access Android content resolver."); - /// - public override Task GetPropertiesAsync() + var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(newName)) ?? "application/octet-stream"; + var newFile = Document?.CreateFile(mimeType, newName); + if (newFile?.Uri is null) + throw new UnauthorizedAccessException($"Could not create file '{newName}'."); + + await using var inputStream = activity.ContentResolver.OpenInputStream(source.Inner) ?? throw new IOException("Could not open source file for reading."); + await using var outputStream = activity.ContentResolver.OpenOutputStream(newFile.Uri) ?? throw new IOException("Could not open destination file for writing."); + await inputStream.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); + + return new AndroidFile(newFile.Uri, activity, this, permissionRoot); + } + + private Task RenameInternalAsync(AndroidFile source, string newName) { - if (Document is null) - return Task.FromException(new ArgumentNullException(nameof(Document))); + if (activity.ContentResolver is null) + return Task.FromException(new UnauthorizedAccessException("Could not access Android content resolver.")); + + var renamedUri = DocumentsContract.RenameDocument(activity.ContentResolver, source.Inner, newName); + if (renamedUri is null) + return Task.FromException(RenameException); + + return Task.FromResult(new AndroidFile(renamedUri, activity, this, permissionRoot)); + } + + private async Task MoveInternalAsync(AndroidFile source, IModifiableFolder sourceFolder, string newName, CancellationToken cancellationToken) + { + // Try native move via DocumentsContract (API 26+) + if (OperatingSystem.IsAndroidVersionAtLeast(26) + && activity.ContentResolver is not null + && source.Parent is { } androidSourceFolder) + { + var movedUri = DocumentsContract.MoveDocument(activity.ContentResolver, source.Inner, androidSourceFolder.Inner, Inner); + if (movedUri is not null) + { + // The file was moved but may still need renaming if the name changed + if (source.Name != newName) + { + var renamedUri = DocumentsContract.RenameDocument(activity.ContentResolver, movedUri, newName); + if (renamedUri is not null) + return new AndroidFile(renamedUri, activity, this, permissionRoot); + + // Move succeeded, but rename failed - still return the moved file under the original name + } + + return new AndroidFile(movedUri, activity, this, permissionRoot); + } + } - properties ??= new AndroidFolderProperties(Document); - return Task.FromResult(properties); + // Fallback: copy then delete + var copiedFile = await CopyInternalAsync(source, newName, cancellationToken).ConfigureAwait(false); + await sourceFolder.DeleteAsync(source, cancellationToken).ConfigureAwait(false); + return copiedFile; } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs index ddb5874d1..deccb7d7a 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs @@ -4,20 +4,17 @@ using OwlCore.Storage; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Storage; -using SecureFolderFS.Storage.StorageProperties; -using SecureFolderFS.UI; using Activity = Android.App.Activity; using AndroidUri = Android.Net.Uri; +using Constants = SecureFolderFS.UI.Constants; namespace SecureFolderFS.Maui.Platforms.Android.Storage { /// - internal abstract class AndroidStorable : IStorableChild, IStorableProperties, IBookmark, IWrapper + internal abstract class AndroidStorable : IStorableChild, IBookmark, IWrapper { protected readonly Activity activity; - protected readonly AndroidFolder? parent; protected readonly AndroidUri permissionRoot; - protected IBasicProperties? properties; /// public AndroidUri Inner { get; } @@ -30,6 +27,11 @@ internal abstract class AndroidStorable : IStorableChild, IStorableProperties, I /// public string? BookmarkId { get; protected set; } + + /// + /// Gets the parent folder of the current object. + /// + internal AndroidFolder? Parent { get; } /// /// Gets the associated with the storage type identified by . @@ -39,7 +41,7 @@ internal abstract class AndroidStorable : IStorableChild, IStorableProperties, I protected AndroidStorable(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null) { this.activity = activity; - this.parent = parent; + this.Parent = parent; this.permissionRoot = permissionRoot ?? uri; Inner = uri; @@ -51,7 +53,7 @@ protected AndroidStorable(AndroidUri uri, Activity activity, AndroidFolder? pare /// public virtual Task GetParentAsync(CancellationToken cancellationToken = default) { - return Task.FromResult(parent); + return Task.FromResult(Parent); } /// @@ -90,9 +92,6 @@ public Task RemoveBookmarkAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } - /// - public abstract Task GetPropertiesAsync(); - protected static string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null) { try diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFileProperties.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFileProperties.cs deleted file mode 100644 index f911eba97..000000000 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFileProperties.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Runtime.CompilerServices; -using AndroidX.DocumentFile.Provider; -using OwlCore.Storage; -using SecureFolderFS.Storage.StorageProperties; - -namespace SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties -{ - /// - internal sealed class AndroidFileProperties : ISizeProperties, IDateProperties, IBasicProperties - { - private readonly DocumentFile _document; - - public AndroidFileProperties(DocumentFile document) - { - _document = document; - } - - /// - public Task?> GetSizeAsync(CancellationToken cancellationToken = default) - { - var sizeProperty = new GenericProperty(_document.Length()); - return Task.FromResult?>(sizeProperty); - } - - /// - public Task> GetDateCreatedAsync(CancellationToken cancellationToken = default) - { - // Created date is not available on Android - return GetDateModifiedAsync(cancellationToken); - } - - /// - public Task> GetDateModifiedAsync(CancellationToken cancellationToken = default) - { - var timestamp = _document.LastModified(); - var dateModified = DateTimeOffset.FromUnixTimeMilliseconds(timestamp).UtcDateTime; - var dateProperty = new GenericProperty(dateModified); - - return Task.FromResult>(dateProperty); - } - - /// - public async IAsyncEnumerable> GetPropertiesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) - { - yield return await GetSizeAsync(cancellationToken) as IStorageProperty; - yield return await GetDateCreatedAsync(cancellationToken) as IStorageProperty; - yield return await GetDateModifiedAsync(cancellationToken) as IStorageProperty; - } - } -} - diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFolderProperties.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFolderProperties.cs deleted file mode 100644 index 35165bf87..000000000 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidFolderProperties.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Runtime.CompilerServices; -using AndroidX.DocumentFile.Provider; -using OwlCore.Storage; -using SecureFolderFS.Storage.StorageProperties; - -namespace SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties -{ - /// - internal sealed class AndroidFolderProperties : IDateProperties, IBasicProperties - { - private readonly DocumentFile _document; - - public AndroidFolderProperties(DocumentFile document) - { - _document = document; - } - - /// - public Task> GetDateCreatedAsync(CancellationToken cancellationToken = default) - { - // Created date is not available on Android - return GetDateModifiedAsync(cancellationToken); - } - - /// - public Task> GetDateModifiedAsync(CancellationToken cancellationToken = default) - { - var timestamp = _document.LastModified(); - var dateModified = DateTimeOffset.FromUnixTimeMilliseconds(timestamp).UtcDateTime; - var dateProperty = new GenericProperty(dateModified); - - return Task.FromResult>(dateProperty); - } - - /// - public async IAsyncEnumerable> GetPropertiesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) - { - yield return await GetDateCreatedAsync(cancellationToken) as IStorageProperty; - yield return await GetDateModifiedAsync(cancellationToken) as IStorageProperty; - } - } -} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidLastModifiedAtProperty.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidLastModifiedAtProperty.cs new file mode 100644 index 000000000..a71733d8b --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidLastModifiedAtProperty.cs @@ -0,0 +1,33 @@ +using AndroidX.DocumentFile.Provider; +using OwlCore.Storage; + +namespace SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties +{ + /// + internal sealed class AndroidLastModifiedAtProperty : ILastModifiedAtProperty + { + private readonly DocumentFile _document; + + /// + public string Id { get; } + + /// + public string Name { get; } + + public AndroidLastModifiedAtProperty(string id, DocumentFile document) + { + _document = document; + Name = nameof(ILastModifiedAt.LastModifiedAt); + Id = $"{id}/{nameof(ILastModifiedAt.LastModifiedAt)}"; + } + + /// + public Task GetValueAsync(CancellationToken cancellationToken = default) + { + var timestamp = _document.LastModified(); + var dateModified = DateTimeOffset.FromUnixTimeMilliseconds(timestamp).UtcDateTime; + + return Task.FromResult(dateModified); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidSizeOfProperty.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidSizeOfProperty.cs new file mode 100644 index 000000000..a3de5c2ca --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/StorageProperties/AndroidSizeOfProperty.cs @@ -0,0 +1,30 @@ +using AndroidX.DocumentFile.Provider; +using SecureFolderFS.Storage.StorageProperties; + +namespace SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties +{ + /// + internal sealed class AndroidSizeOfProperty : ISizeOfProperty + { + private readonly DocumentFile _document; + + /// + public string Id { get; } + + /// + public string Name { get; } + + public AndroidSizeOfProperty(string id, DocumentFile document) + { + _document = document; + Name = nameof(ISizeOf.SizeOf); + Id = $"{id}/{nameof(ISizeOf.SizeOf)}"; + } + + /// + public Task GetValueAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(_document.Length()); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Templates/AndroidDataTemplates.xaml b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Templates/AndroidDataTemplates.xaml index 1226a0e2f..cb8b61ffd 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Templates/AndroidDataTemplates.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Templates/AndroidDataTemplates.xaml @@ -25,7 +25,7 @@