diff --git a/SecureFolderFS.sln b/SecureFolderFS.sln index 2db6f4db5..8a06d2391 100644 --- a/SecureFolderFS.sln +++ b/SecureFolderFS.sln @@ -76,6 +76,8 @@ 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.PhoneLink", "src\Sdk\SecureFolderFS.Sdk.PhoneLink\SecureFolderFS.Sdk.PhoneLink.csproj", "{85FE77EA-9F89-4F42-BD79-26C82F847DDC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -504,6 +506,22 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -539,6 +557,7 @@ 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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A1906FD8-BB54-4688-BC0F-9ED7532D2CB0} diff --git a/global.json b/global.json index 4d9e028b1..07b8d96ae 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "msbuild-sdks": { - "Uno.Sdk": "6.0.96" + "Uno.Sdk": "6.4.24" } } \ No newline at end of file diff --git a/lib/nwebdav b/lib/nwebdav index fec42e99c..3b65da651 160000 --- a/lib/nwebdav +++ b/lib/nwebdav @@ -1 +1 @@ -Subproject commit fec42e99c8593f5de31ea3098d4b2f7cbaabcfce +Subproject commit 3b65da651fdd8c7f5329e985ff8f08c532b19e19 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 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/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.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..f27dba2df 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/AppModels/HealthStatistics.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/AppModels/HealthStatistics.cs @@ -1,4 +1,4 @@ -using OwlCore.Storage; +using OwlCore.Storage; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Storage.VirtualFileSystem; using System; @@ -11,6 +11,9 @@ public sealed class HealthStatistics : IHealthStatistics /// public IAsyncValidator? FileValidator { get; set; } + /// + public IAsyncValidator? FileContentValidator { get; set; } + /// public IAsyncValidator? FolderValidator { 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..605b4ba51 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs @@ -63,6 +63,9 @@ public virtual int CopyFromChunk(long chunkNumber, Span destination, int o } finally { + // Clear sensitive plaintext data before returning buffer to 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); } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs index c9ecbaa9d..456374fa2 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs @@ -7,6 +7,7 @@ using System; using System.Buffers; using System.Diagnostics; +using System.Security.Cryptography; namespace SecureFolderFS.Core.FileSystem.Chunks { @@ -104,6 +105,9 @@ 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); } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs index 7d2a2fdc7..990c6da36 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs @@ -6,6 +6,7 @@ using SecureFolderFS.Storage.VirtualFileSystem; using System; using System.Buffers; +using System.Security.Cryptography; namespace SecureFolderFS.Core.FileSystem.Chunks { @@ -75,6 +76,9 @@ public void WriteChunk(long chunkNumber, ReadOnlySpan plaintextChunk) } finally { + // Clear ciphertext data before returning buffer to pool + CryptographicOperations.ZeroMemory(ciphertextChunk.AsSpan(0, ciphertextSize)); + // Return buffer ArrayPool.Shared.Return(ciphertextChunk); } 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/Extensions/FileSystemOptionsExtensions.cs b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileSystemOptionsExtensions.cs index 5c785301f..50cd470d2 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileSystemOptionsExtensions.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileSystemOptionsExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using SecureFolderFS.Core.FileSystem.Validators; using SecureFolderFS.Storage.VirtualFileSystem; @@ -10,6 +10,7 @@ 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); } 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/RecycleBin/Abstract/AbstractRecycleBinHelpers.Shared.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Shared.cs index 21a3f473c..0dddb76dd 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,12 @@ +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.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,17 +15,18 @@ 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)) - return 0L; + var recycleBinConfig = await recycleBin.TryGetFileByNameAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, cancellationToken); + recycleBinConfig ??= await recycleBin.CreateFileAsync(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, false, cancellationToken); - return Math.Max(0L, value); + var text = await recycleBinConfig.ReadAllTextAsync(null, cancellationToken); + return !long.TryParse(text, out var value) ? 0L : Math.Max(0L, value); } 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); + 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 recycleBinConfig.WriteAllTextAsync(Math.Max(0L, value).ToString(), null, cancellationToken); } @@ -47,18 +48,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 +59,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/Storage/StorageProperties/CryptoFileProperties.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoFileProperties.cs index c14377abd..493ff9cac 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoFileProperties.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoFileProperties.cs @@ -30,7 +30,8 @@ public CryptoFileProperties(FileSystemSpecifics specifics, IBasicProperties prop if (sizeProperty is null) return null; - var plaintextSize = _specifics.Security.ContentCrypt.CalculatePlaintextSize(sizeProperty.Value); + var ciphertextSize = sizeProperty.Value - _specifics.Security.HeaderCrypt.HeaderCiphertextSize; + var plaintextSize = _specifics.Security.ContentCrypt.CalculatePlaintextSize(ciphertextSize); return new GenericProperty(plaintextSize); } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs index 23a94bbcb..22ddfc1db 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs @@ -143,12 +143,12 @@ public override void Write(ReadOnlySpan buffer) // 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 secure noise array + WriteInternal(secureNoise, Length); } else { 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.Migration/AppModels/MigratorV1_V2.cs b/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV1_V2.cs index e80db7315..0c7977e53 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 { @@ -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)}."); @@ -53,10 +52,10 @@ public async Task UnlockAsync(IKey credentials, CancellationToken c throw new FormatException($"{nameof(VaultKeystoreDataModel)} 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.Old_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..84ebc21dd 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 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); @@ -66,8 +70,8 @@ public async Task UnlockAsync(IKey credentials, CancellationToken c throw new FormatException($"{nameof(VaultKeystoreDataModel)} 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); @@ -101,21 +105,28 @@ public async Task RecoverAsync(string encodedRecoveryKey, Cancellat 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)); + // 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); + // Fill the hash to payload + hmacSha256.GetCurrentHash(v3ConfigDataModel.PayloadMac); - // Vault Keystore ------------------------------------ - // - var kek = new byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; - Argon2id.V3_DeriveKey(_secretKeySequence, _v2KeystoreDataModel.Salt, kek); + // Vault Keystore ------------------------------------ + // + Span kek = stackalloc byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; + Argon2id.V3_DeriveKey(_secretKeySequence, _v2KeystoreDataModel.Salt, kek); - using var rfc3394 = new Rfc3394KeyWrap(); - var newWrappedDekKek = rfc3394.WrapKey(dekKey, kek); - var newWrappedMacKek = rfc3394.WrapKey(macKey, kek); + using var rfc3394 = new Rfc3394KeyWrap(); + var newWrappedDekKey = rfc3394.WrapKey(dek, kek); + var newWrappedMacKey = rfc3394.WrapKey(mac, kek); + + return (newWrappedDekKey, newWrappedMacKey); + }); var v3KeystoreDataModel = new VaultKeystoreDataModel() { 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.WebDav/EncryptingStorage2/EncryptingDiskStore.cs b/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStore.cs index 149c6a4f8..d9aa26ff3 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStore.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStore.cs @@ -1,4 +1,5 @@ using NWebDav.Server.Locking; +using NWebDav.Server.Storage; using NWebDav.Server.Stores; using SecureFolderFS.Core.FileSystem; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; @@ -19,32 +20,32 @@ public EncryptingDiskStore(string directory, FileSystemSpecifics specifics, bool _specifics = specifics; } - public override Task GetItemAsync(Uri uri, CancellationToken cancellationToken) + 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)); + 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)); + return Task.FromResult(new EncryptingDiskStoreFile(LockingManager, new FileInfo(path), IsWritable, _specifics)); // The item doesn't exist - return Task.FromResult(null); + return Task.FromResult(null); } - public override Task GetCollectionAsync(Uri uri, CancellationToken cancellationToken) + 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 Task.FromResult(null); // Return the item - return Task.FromResult(new EncryptingDiskStoreCollection(LockingManager, new DirectoryInfo(path), IsWritable, _specifics)); + return Task.FromResult(new EncryptingDiskStoreCollection(LockingManager, new DirectoryInfo(path), IsWritable, _specifics)); } protected override string GetPathFromUri(Uri uri) diff --git a/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreCollection.cs b/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreCollection.cs index 91f135a1a..fb9158c44 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreCollection.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreCollection.cs @@ -1,13 +1,4 @@ -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; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -17,11 +8,22 @@ using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; +using NWebDav.Server; +using NWebDav.Server.Enums; +using NWebDav.Server.Locking; +using NWebDav.Server.Props; +using NWebDav.Server.Storage; +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 SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Core.WebDav.EncryptingStorage2 { - internal sealed class EncryptingDiskStoreCollection : IStoreCollection + internal sealed class EncryptingDiskStoreCollection : IDavFolder { private static readonly XElement s_xDavCollection = new XElement(WebDavNamespaces.DavNs + "collection"); private readonly DirectoryInfo _directoryInfo; @@ -33,6 +35,12 @@ internal sealed class EncryptingDiskStoreCollection : IStoreCollection /// public string Name { get; } + /// + public EnumerationDepthMode DepthMode => EnumerationDepthMode.Rejected; + + /// + IFolder? IWrapper.Inner => null; + public EncryptingDiskStoreCollection(ILockingManager lockingManager, DirectoryInfo directoryInfo, bool isWritable, FileSystemSpecifics specifics) { _specifics = specifics; @@ -45,7 +53,7 @@ public EncryptingDiskStoreCollection(ILockingManager lockingManager, DirectoryIn } /// - public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) { await Task.CompletedTask; cancellationToken.ThrowIfCancellationRequested(); @@ -59,7 +67,7 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = Stor if (PathHelpers.IsCoreName(file.Name)) continue; - yield return new DiskStoreFile(LockingManager, file, IsWritable); + yield return new EncryptingDiskStoreFile(LockingManager, file, IsWritable, _specifics); } break; @@ -72,7 +80,7 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = Stor if (PathHelpers.IsCoreName(folder.Name)) continue; - yield return new DiskStoreCollection(LockingManager, folder, IsWritable); + yield return new EncryptingDiskStoreCollection(LockingManager, folder, IsWritable, _specifics); } break; @@ -85,7 +93,6 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = Stor if (PathHelpers.IsCoreName(folder.Name)) continue; - yield return new EncryptingDiskStoreCollection(LockingManager, folder, IsWritable, _specifics); } @@ -103,7 +110,24 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = Stor } /// - public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken) + public Task GetFolderWatcherAsync(CancellationToken cancellationToken = new CancellationToken()) + { + // Folder watcher in WebDav is not supported + throw new NotSupportedException(); + } + + /// + public Task GetParentAsync(CancellationToken cancellationToken = default) + { + var parentDirectory = _directoryInfo.Parent; + if (parentDirectory is null) + return Task.FromResult(null); + + return Task.FromResult(new DiskStoreCollection(LockingManager, parentDirectory, IsWritable)); + } + + /// + public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default) { await Task.CompletedTask; cancellationToken.ThrowIfCancellationRequested(); @@ -124,7 +148,7 @@ public async Task GetFirstByNameAsync(string name, CancellationToken } /// - public async Task MoveItemAsync(IStoreItem storeItem, IStoreCollection destinationCollection, string destinationName, bool overwrite, CancellationToken cancellationToken) + public async Task MoveItemAsync(IDavStorable item, IDavFolder destination, string destinationName, bool overwrite, CancellationToken cancellationToken = default) { // Return error if (!IsWritable) @@ -133,14 +157,14 @@ public async Task MoveItemAsync(IStoreItem storeItem, IStoreCollecti try { // If the destination collection is a directory too, then we can simply move the file - if (destinationCollection is EncryptingDiskStoreCollection destinationDiskStoreCollection) + if (destination 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 sourcePath = NativePathHelpers.GetCiphertextPath(item.Id, _specifics); var destinationPath = NativePathHelpers.GetCiphertextPath(Path.Combine(destinationDiskStoreCollection.Id, destinationName), _specifics); // Check if the file already exists @@ -171,7 +195,7 @@ public async Task MoveItemAsync(IStoreItem storeItem, IStoreCollecti result = HttpStatusCode.Created; } - switch (storeItem) + switch (item) { case EncryptingDiskStoreFile _: // Move the file @@ -185,17 +209,17 @@ public async Task MoveItemAsync(IStoreItem storeItem, IStoreCollecti default: // Invalid item - Debug.Fail($"Invalid item {storeItem.GetType()} inside the {nameof(DiskStoreCollection)}."); + Debug.Fail($"Invalid item {item.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); + var result = await item.CopyAsync(destination, destinationName, overwrite, cancellationToken).ConfigureAwait(false); if (result.Result == HttpStatusCode.Created || result.Result == HttpStatusCode.NoContent) { - await DeleteAsync(storeItem, cancellationToken).ConfigureAwait(false); + await DeleteAsync(item, cancellationToken).ConfigureAwait(false); return result.Item!; } else @@ -211,7 +235,7 @@ public async Task MoveItemAsync(IStoreItem storeItem, IStoreCollecti } /// - public async Task DeleteAsync(IStoreItem storeItem, CancellationToken cancellationToken) + public async Task DeleteAsync(IStorableChild item, CancellationToken cancellationToken = default) { await Task.CompletedTask; @@ -220,7 +244,7 @@ public async Task DeleteAsync(IStoreItem storeItem, CancellationToken cancellati throw new HttpListenerException((int)HttpStatusCode.PreconditionFailed); // Determine the full path - var fullPath = NativePathHelpers.GetCiphertextPath(Path.Combine(Id, storeItem.Name), _specifics); + var fullPath = NativePathHelpers.GetCiphertextPath(Path.Combine(Id, item.Name), _specifics); try { // Check if the file exists @@ -256,6 +280,95 @@ public async Task DeleteAsync(IStoreItem storeItem, CancellationToken cancellati } + /// + public async Task CreateFolderAsync(string name, bool overwrite = false, + CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + + // Return error + if (!IsWritable) + throw new HttpListenerException((int)HttpStatusCode.Forbidden); + + // Determine the destination path + var destinationPath = NativePathHelpers.GetCiphertextPath(Path.Combine(Id, name), _specifics); + + // Check if the directory can be overwritten + if (Directory.Exists(destinationPath)) + { + // Check if overwrite is allowed + if (!overwrite) + throw new HttpListenerException((int)HttpStatusCode.MethodNotAllowed); + } + + 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 + await 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 (UnauthorizedAccessException) + { + throw new HttpListenerException((int)HttpStatusCode.Forbidden); + } + catch (Exception) + { + throw new HttpListenerException((int)HttpStatusCode.InternalServerError); + } + + // Return the collection + return new EncryptingDiskStoreCollection(LockingManager, new DirectoryInfo(destinationPath), IsWritable, _specifics); + } + + /// + public async Task CreateFileAsync(string name, bool overwrite = false, + CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + + // Return error + if (!IsWritable) + throw new HttpListenerException((int)HttpStatusCode.Forbidden); + + // Determine the destination path + var destinationPath = NativePathHelpers.GetCiphertextPath(Path.Combine(Id, name), _specifics); + + // Check if the file can be overwritten + if (File.Exists(destinationPath)) + { + if (!overwrite) + throw new HttpListenerException((int)HttpStatusCode.PreconditionFailed); + } + + try + { + // Create a new file + File.Create(destinationPath).Dispose(); + } + catch (UnauthorizedAccessException) + { + throw new HttpListenerException((int)HttpStatusCode.Forbidden); + } + catch (Exception) + { + throw new HttpListenerException((int)HttpStatusCode.InternalServerError); + } + + // Return result + return new EncryptingDiskStoreFile(LockingManager, new FileInfo(destinationPath), IsWritable, _specifics); + } + + public static PropertyManager DefaultPropertyManager { get; } = new(new DavProperty[] @@ -381,115 +494,24 @@ public async Task DeleteAsync(IStoreItem storeItem, CancellationToken cancellati public IPropertyManager PropertyManager => DefaultPropertyManager; public ILockingManager LockingManager { get; } - public Task CreateItemAsync(string name, bool overwrite, CancellationToken cancellationToken) + public async Task CopyAsync(IDavFolder destination, 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; - } - + // Just create the folder itself 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)); + var result = await destination.CreateFolderAsync(name, overwrite, cancellationToken).ConfigureAwait(false); + return new StoreItemResult(HttpStatusCode.Created, (IDavStorable)result); } - catch (Exception exc) + catch (HttpListenerException ex) { - // Log exception - // TODO(wd): Add logging - //s_log.Log(LogLevel.Error, () => $"Unable to create '{destinationPath}' directory.", exc); - return null; + return new StoreItemResult((HttpStatusCode)ex.ErrorCode); } - - // 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) + public bool SupportsFastMove(IDavFolder 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 index a4c50e2d0..2abdb7b95 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreFile.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/EncryptingStorage2/EncryptingDiskStoreFile.cs @@ -1,22 +1,34 @@ -using NWebDav.Server.Helpers; +using System; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using NWebDav.Server.Helpers; using NWebDav.Server.Locking; using NWebDav.Server.Props; +using NWebDav.Server.Storage; using NWebDav.Server.Stores; +using OwlCore.Storage; 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; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Core.WebDav.EncryptingStorage2 { - internal class EncryptingDiskStoreFile : IStoreFile + internal class EncryptingDiskStoreFile : IDavFile { private readonly FileSystemSpecifics _specifics; private readonly FileInfo _fileInfo; + /// + public string Name => Path.GetFileName(Id); + + /// + public string Id => NativePathHelpers.GetPlaintextPath(_fileInfo.FullName, _specifics) ?? string.Empty; + + /// + IFile? IWrapper.Inner => null; + public EncryptingDiskStoreFile(ILockingManager lockingManager, FileInfo fileInfo, bool isWritable, FileSystemSpecifics specifics) { LockingManager = lockingManager; @@ -25,6 +37,31 @@ public EncryptingDiskStoreFile(ILockingManager lockingManager, FileInfo fileInfo _specifics = specifics; } + /// + public Task OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default) + { + var baseStream = accessMode switch + { + FileAccess.Read => _fileInfo.OpenRead(), + FileAccess.Write => _fileInfo.Open(FileMode.OpenOrCreate, FileAccess.Write), + FileAccess.ReadWrite => _fileInfo.Open(FileMode.OpenOrCreate, FileAccess.ReadWrite), + _ => throw new ArgumentOutOfRangeException(nameof(accessMode), accessMode, null) + }; + + var plaintextStream = _specifics.StreamsAccess.OpenPlaintextStream(_fileInfo.FullName, baseStream); + return Task.FromResult(plaintextStream); + } + + /// + public Task GetParentAsync(CancellationToken cancellationToken = default) + { + var parentDirectory = _fileInfo.Directory; + if (parentDirectory is null) + return Task.FromResult(null); + + return Task.FromResult(new EncryptingDiskStoreCollection(LockingManager, parentDirectory, IsWritable, _specifics)); + } + public static PropertyManager DefaultPropertyManager { get; } = new(new DavProperty[] { // RFC-2518 properties @@ -118,40 +155,12 @@ public EncryptingDiskStoreFile(ILockingManager lockingManager, FileInfo fileInfo }); 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) + public async Task CopyAsync(IDavFolder destination, string name, bool overwrite, CancellationToken cancellationToken) { try { @@ -179,26 +188,35 @@ public async Task CopyAsync(IStoreCollection destination, strin else { // Create the item in the destination collection - var result = await destination.CreateItemAsync(name, overwrite, cancellationToken).ConfigureAwait(false); + IDavFile davFile; + try + { + davFile = (IDavFile)await destination.CreateFileAsync(name, overwrite, cancellationToken).ConfigureAwait(false); + } + catch (HttpListenerException ex) + { + return new StoreItemResult((HttpStatusCode)ex.ErrorCode); + } - // Check if the item could be created - if (result.Item is IStoreFile storeFile) + // Copy the file content + try + { + await using var sourceStream = await OpenStreamAsync(FileAccess.Read, cancellationToken).ConfigureAwait(false); + await using var destinationStream = await davFile.OpenStreamAsync(FileAccess.Write, cancellationToken).ConfigureAwait(false); + await sourceStream.CopyToAsync(destinationStream, cancellationToken).ConfigureAwait(false); + await destinationStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + catch (IOException ioException) when (ioException.IsDiskFull()) { - 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); - } + return new StoreItemResult(HttpStatusCode.InsufficientStorage, davFile); } - else + catch (UnauthorizedAccessException) { - // Item is directory - return new(HttpStatusCode.Conflict, result.Item); + return new StoreItemResult(HttpStatusCode.Forbidden, davFile); } // Return result - return new StoreItemResult(result.Result, result.Item); + return new StoreItemResult(HttpStatusCode.Created, davFile); } } catch (Exception exc) diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs index 81cb395a6..193f04078 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs @@ -17,6 +17,7 @@ using System.Net; using System.Threading; using System.Threading.Tasks; +using SecureFolderFS.Core.FileSystem.Storage; namespace SecureFolderFS.Core.WebDav { @@ -56,8 +57,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, diff --git a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs index 8ac62d3f2..14e333af6 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs @@ -507,7 +507,7 @@ public override int SetBasicInfo( dirHandle.DirectoryInfo.LastAccessTimeUtc = DateTime.FromFileTimeUtc((long)LastAccessTime); dirHandle.DirectoryInfo.Attributes = (FileAttributes)FileAttributes; FileInfo = dirHandle.GetFileInfo(); - + break; } @@ -902,7 +902,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/Constants.cs b/src/Core/SecureFolderFS.Core/Constants.cs index a0098faf1..0de60f88f 100644 --- a/src/Core/SecureFolderFS.Core/Constants.cs +++ b/src/Core/SecureFolderFS.Core/Constants.cs @@ -20,11 +20,10 @@ 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_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] 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..4c00b65af 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; @@ -20,8 +21,8 @@ internal sealed class CreationRoutine : ICreationRoutine private readonly VaultWriter _vaultWriter; private VaultKeystoreDataModel? _keystoreDataModel; private VaultConfigurationDataModel? _configDataModel; - private SecretKey? _macKey; - private SecretKey? _dekKey; + private IKeyUsage? _dekKey; + private IKeyUsage? _macKey; public CreationRoutine(IFolder vaultFolder, VaultWriter vaultWriter) { @@ -36,25 +37,24 @@ 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.EncryptKeystore(key, dekKey, macKey, salt)); // Create key copies for later use - _macKey = macKey.CreateCopy(); - _dekKey = dekKey.CreateCopy(); + _dekKey = SecureKey.TakeOwnership(dekKey); + _macKey = SecureKey.TakeOwnership(macKey); } /// @@ -71,14 +71,17 @@ 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.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..05fd5380e 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs @@ -47,17 +47,27 @@ 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 new keystore - _keystoreDataModel = VaultParser.EncryptKeystore(passkey, _keyPair.DekKey, _keyPair.MacKey, 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.EncryptKeystore(k, dekKey, macKey, salt); + }); + } + }); } /// @@ -66,8 +76,11 @@ 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); @@ -75,7 +88,7 @@ public async Task FinalizeAsync(CancellationToken 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..a38e0542e 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs @@ -6,6 +6,8 @@ using System; using System.Threading; using System.Threading.Tasks; +using SecureFolderFS.Core.Cryptography.Extensions; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Core.Routines.Operational { @@ -15,8 +17,8 @@ internal sealed class UnlockRoutine : ICredentialsRoutine private readonly VaultReader _vaultReader; private VaultKeystoreDataModel? _keystoreDataModel; private VaultConfigurationDataModel? _configDataModel; - private SecretKey? _dekKey; - private SecretKey? _macKey; + private SecureKey? _dekKey; + private SecureKey? _macKey; public UnlockRoutine(VaultReader vaultReader) { @@ -31,15 +33,16 @@ public async Task InitAsync(CancellationToken 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; + var derived = passkey.UseKey(key => VaultParser.DeriveKeystore(key, _keystoreDataModel)); + + _dekKey = SecureKey.TakeOwnership(derived.dekKey); + _macKey = SecureKey.TakeOwnership(derived.macKey); } /// @@ -53,11 +56,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/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..4d64b77b3 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,19 +43,19 @@ 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) DeriveKeystore(ReadOnlySpan passkey, VaultKeystoreDataModel 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.V3_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); } @@ -70,9 +70,9 @@ public static (SecretKey dekKey, SecretKey macKey) DeriveKeystore(SecretKey pass /// A new instance of containing the encrypted cryptographic keys. [SkipLocalsInit] public static VaultKeystoreDataModel EncryptKeystore( - SecretKey passkey, - SecretKey dekKey, - SecretKey macKey, + ReadOnlySpan passkey, + ReadOnlySpan dekKey, + ReadOnlySpan macKey, byte[] salt) { // Derive KEK diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props index d0379f17e..27e61ad97 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -2,25 +2,26 @@ - + - + - - - - - + + + + + - + + @@ -39,5 +40,6 @@ + - + \ No newline at end of file diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/MauiOAuthHandler.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/MauiOAuthHandler.cs index 0911f5391..57f75f4c2 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() { } @@ -96,7 +96,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 +104,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/AppShell.xaml.cs b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs index a899d09a8..0d6fefba2 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppShell.xaml.cs @@ -4,6 +4,7 @@ using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels; using SecureFolderFS.Sdk.ViewModels.Views.Overlays; +using SecureFolderFS.Sdk.ViewModels.Views.Root; using SecureFolderFS.Shared; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.UI.Helpers; diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs index 02aba0257..43b52fc1e 100644 --- a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Picker.cs @@ -44,22 +44,22 @@ public static void AddPickerMappers() handler.PlatformView.SetPadding(32, 24, 32, 24); #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(); - + // Add the chevron icon on the right var chevronConfig = UIImageSymbolConfiguration.Create(UIImageSymbolScale.Small); var chevronImage = UIImage.GetSystemImage("chevron.up.chevron.down", chevronConfig); @@ -68,12 +68,12 @@ public static void AddPickerMappers() ContentMode = UIViewContentMode.ScaleAspectFit, TintColor = uiTextField.TextColor }; - + // 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; #else diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/PopupExtensions.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/PopupExtensions.cs new file mode 100644 index 000000000..a84fadf3c --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/PopupExtensions.cs @@ -0,0 +1,218 @@ +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 FadeAnimationDuration = 300; + private const double OverlayOpacity = 0.6; + + /// + /// 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 Task OverlayPopupAsync(this Page page, Popup popup, bool dismissOnBackgroundTap = true) + { + if (page is not ContentPage contentPage) + return Task.CompletedTask; + + var completionSource = new TaskCompletionSource(); + + // Get the current content + var originalContent = contentPage.Content; + if (originalContent is null) + { + completionSource.SetResult(); + return completionSource.Task; + } + + // Create the overlay grid that will hold both original content and popup + var overlayGrid = new Grid(); + + // Remove original content from page first + contentPage.Content = null; + + // Add original content to overlay grid + overlayGrid.Children.Add(originalContent); + + // Create the dimming background + var dimBackground = new BoxView() + { + Color = MauiThemeHelper.Instance.CurrentTheme == ThemeType.Light ? Colors.Black : Colors.DimGray, + Opacity = 0, + InputTransparent = false + }; + overlayGrid.Children.Add(dimBackground); + + // Get the popup's content and detach it from the popup + if (popup.Content is null) + { + // Restore original content if popup has no content + contentPage.Content = originalContent; + completionSource.SetResult(); + return completionSource.Task; + } + + // Create a container for the popup content with centering + var popupContainer = new Grid() + { + HorizontalOptions = LayoutOptions.Fill, + VerticalOptions = LayoutOptions.Center, + InputTransparent = false, + CascadeInputTransparent = false, + Opacity = 0 + }; + + // Add the popup content to the container + popup.HorizontalOptions = LayoutOptions.Fill; + popup.VerticalOptions = LayoutOptions.Center; + popup.CascadeInputTransparent = false; + popup.InputTransparent = false; + popup.Content.InputTransparent = false; + + popupContainer.Children.Add(popup); + + overlayGrid.Children.Add(popupContainer); + + // Set the overlay grid as page content + contentPage.Content = overlayGrid; + + // Track if already closing to prevent double-close + var isClosing = false; + + // Event handlers that we need to unhook later + TapGestureRecognizer? tapGesture = null; + EventHandler? tappedHandler = null; + EventHandler? closedHandler = null; + EventHandler? navigatedFromHandler = null; + + // Store the original back button behavior to restore later + var originalBackButtonBehavior = Shell.GetBackButtonBehavior(contentPage); + + void CleanupOverlay() + { + if (isClosing) + return; + + isClosing = true; + + // Unhook all event handlers + if (tapGesture is not null) + { + if (tappedHandler is not null) + tapGesture.Tapped -= tappedHandler; + + dimBackground.GestureRecognizers.Remove(tapGesture); + } + + if (closedHandler is not null) + popup.Closed -= closedHandler; + + if (navigatedFromHandler is not null) + contentPage.NavigatedFrom -= navigatedFromHandler; + + // Restore the original back button behavior + Shell.SetBackButtonBehavior(contentPage, originalBackButtonBehavior); + + // Remove overlay and restore original content + overlayGrid.Children.Clear(); + contentPage.Content = originalContent; + + completionSource.TrySetResult(); + } + + // Define the close action with animation + async Task CloseOverlayAsync() + { + if (isClosing) + return; + + isClosing = true; + + // Unhook all event handlers + if (tapGesture is not null) + { + if (tappedHandler is not null) + tapGesture.Tapped -= tappedHandler; + + dimBackground.GestureRecognizers.Remove(tapGesture); + } + + if (closedHandler is not null) + popup.Closed -= closedHandler; + + if (navigatedFromHandler is not null) + contentPage.NavigatedFrom -= navigatedFromHandler; + + // Restore the original back button behavior + Shell.SetBackButtonBehavior(contentPage, originalBackButtonBehavior); + + // Fade out animations + await Task.WhenAll( + dimBackground.FadeToAsync(0, FadeAnimationDuration, Easing.CubicOut), + popupContainer.FadeToAsync(0, FadeAnimationDuration, Easing.CubicOut) + ); + + // Remove overlay and restore original content + overlayGrid.Children.Clear(); + contentPage.Content = originalContent; + + completionSource.TrySetResult(); + } + + // Handle background tap for light dismiss + if (dismissOnBackgroundTap) + { + tapGesture = new TapGestureRecognizer(); + tappedHandler = (_, _) => _ = CloseOverlayAsync(); + tapGesture.Tapped += tappedHandler; + dimBackground.GestureRecognizers.Add(tapGesture); + } + + // Subscribe to the popup's Closed event + closedHandler = (_, _) => _ = CloseOverlayAsync(); + popup.Closed += closedHandler; + + // Handle page navigation (e.g., user taps NavigationBar back button) + navigatedFromHandler = (_, _) => CleanupOverlay(); + contentPage.NavigatedFrom += navigatedFromHandler; + + // Handle Android back button/gesture - close popup instead of navigating + var backButtonBehavior = new BackButtonBehavior() + { + Command = new Command(() => _ = CloseOverlayAsync()) + }; + Shell.SetBackButtonBehavior(contentPage, backButtonBehavior); + + // Fade in animations + _ = Task.WhenAll( + dimBackground.FadeToAsync(OverlayOpacity, FadeAnimationDuration, Easing.CubicIn), + popupContainer.FadeToAsync(1, FadeAnimationDuration, Easing.CubicIn) + ); + + return 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); + } +} 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..4f520f78c 100644 --- a/src/Platforms/SecureFolderFS.Maui/Helpers/MauiThemeHelper.cs +++ b/src/Platforms/SecureFolderFS.Maui/Helpers/MauiThemeHelper.cs @@ -6,7 +6,7 @@ namespace SecureFolderFS.Maui.Helpers internal sealed class MauiThemeHelper : ThemeHelper { private static MauiThemeHelper? _Instance; - + /// /// Gets the singleton instance of . /// 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..bce43dd9d 100644 --- a/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs +++ b/src/Platforms/SecureFolderFS.Maui/MauiProgram.cs @@ -2,6 +2,7 @@ using CommunityToolkit.Maui; using LibVLCSharp.MAUI; using Plugin.Maui.BottomSheet.Hosting; +using Plugin.SegmentedControl.Maui; using Xe.AcrylicView; #if ANDROID using MauiIcons.Material; @@ -31,11 +32,12 @@ 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 #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/iOS/ServiceImplementation/IOSFileExplorerService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs index 0d5383526..80b37547b 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSFileExplorerService.cs @@ -8,7 +8,6 @@ using SecureFolderFS.Storage.Pickers; using UIKit; using UniformTypeIdentifiers; -using UTType = MobileCoreServices.UTType; namespace SecureFolderFS.Maui.Platforms.iOS.ServiceImplementation { @@ -41,36 +40,19 @@ public async Task SaveFileAsync(string suggestedName, Stream dataStream, I /// public async Task PickFileAsync(PickerOptions? options, bool offerPersistence = true, CancellationToken cancellationToken = default) { - AssertCanPick(); - using var documentPicker = new UIDocumentPickerViewController([ - UTType.Content, - UTType.Item, - "public.data"], UIDocumentPickerMode.Open); + using var documentPicker = new UIDocumentPickerViewController([UTTypes.Item, UTTypes.Content, UTTypes.Data], asCopy: false); var nsUrl = await PickInternalAsync(documentPicker, cancellationToken); - if (nsUrl is null) - return null; - - var file = new IOSFile(nsUrl); - await file.AddBookmarkAsync(cancellationToken); - - return file; + return nsUrl is null ? null : new IOSFile(nsUrl); } /// public async Task PickFolderAsync(PickerOptions? options, bool offerPersistence = true, CancellationToken cancellationToken = default) { - AssertCanPick(); - using var documentPicker = new UIDocumentPickerViewController([UTTypes.Folder], false); + using var documentPicker = new UIDocumentPickerViewController([UTTypes.Folder], asCopy: false); var nsUrl = await PickInternalAsync(documentPicker, cancellationToken); - if (nsUrl is null) - return null; - - var folder = new IOSFolder(nsUrl); - await folder.AddBookmarkAsync(cancellationToken); - - return folder; + return nsUrl is null ? null : new IOSFolder(nsUrl); } private static async Task PickInternalAsync(UIDocumentPickerViewController documentPicker, CancellationToken cancellationToken) @@ -104,11 +86,5 @@ void DocumentPicker_DidPickDocumentAtUrls(object? sender, UIDocumentPickedAtUrls tcs.TrySetResult(e.Urls[0]); } } - - private static void AssertCanPick() - { - if (!OperatingSystem.IsIOSVersionAtLeast(14)) - throw new NotSupportedException("Picking folders on iOS<14 is not supported."); - } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs index 5344909b9..124906df3 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs @@ -76,7 +76,7 @@ private static async Task GenerateImageThumbnailAsync(Stream strea private static async Task GenerateVideoThumbnailAsync(Stream stream, TimeSpan captureTime) { var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mp4"); - + // Write stream to temp file using NSData for speed using var ms = new MemoryStream(); await stream.CopyToAsync(ms).ConfigureAwait(false); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs index ea1b78638..6ac8c405e 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs @@ -1,5 +1,6 @@ using Foundation; using OwlCore.Storage; +using SecureFolderFS.Maui.Platforms.iOS.Storage; using SecureFolderFS.Sdk.Services; namespace SecureFolderFS.Maui.Platforms.iOS.ServiceImplementation @@ -19,12 +20,18 @@ public async Task GetAvailableFreeSpaceAsync(IFolder storageRoot, Cancella if (string.IsNullOrEmpty(path)) throw new ArgumentException("Invalid storage root path.", nameof(storageRoot)); - var fileManager = NSFileManager.DefaultManager; - var attributes = fileManager.GetFileSystemAttributes(path, out var error); - if (attributes is null || error is not null) - throw new IOException($"Unable to get file system attributes for path: {path}. Error: {error.LocalizedDescription}."); + if (storageRoot is IOSStorable) + { + var fileManager = NSFileManager.DefaultManager; + var attributes = fileManager.GetFileSystemAttributes(path, out var error); + if (attributes is null || error is not null) + throw new IOException( + $"Unable to get file system attributes for path: {path}. Error: {error.LocalizedDescription}."); - return (long)attributes.FreeSize; + return (long)attributes.FreeSize; + } + + throw new NotSupportedException("Unsupported storage root type."); #else await Task.CompletedTask; throw new PlatformNotSupportedException("Only implemented on iOS."); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs index c2c8e405d..cd48e8caf 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs @@ -51,7 +51,7 @@ public override async IAsyncEnumerable GetCreationAsync // Key File yield return new KeyFileCreationViewModel(vaultId); - + // Face ID, Touch ID if (AreBiometricsAvailable(out var biometryType)) yield return new IOSBiometricCreationViewModel(vaultFolder, vaultId, biometryType switch @@ -69,7 +69,7 @@ private static bool AreBiometricsAvailable(out LABiometryType biometryType) using var context = new LAContext(); var result = context.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthenticationWithBiometrics, out _); biometryType = context.BiometryType; - + return result; } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultFileSystemService.cs index 77d1c90d0..c2e0fe37e 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultFileSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultFileSystemService.cs @@ -45,12 +45,12 @@ public override async IAsyncEnumerable GetSources { Icon = new ImageResourceFile("source_gdrive.png", false) }; - + yield return new AccountSourceWizardViewModel($"{nameof(SecureFolderFS)}.OneDrive", "OneDrive".ToLocalized(), mode, vaultCollectionModel) { Icon = new ImageResourceFile("source_onedrive.png", false) }; - + yield return new AccountSourceWizardViewModel($"{nameof(SecureFolderFS)}.AmazonS3", "AmazonS3".ToLocalized(), mode, vaultCollectionModel) { Icon = new ImageResourceFile("source_aws_s3.png", false) diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricCreationViewModel.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricCreationViewModel.cs index 6eb158fe0..6e0f29b6c 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricCreationViewModel.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricCreationViewModel.cs @@ -1,10 +1,13 @@ +using System.Security.Cryptography; using OwlCore.Storage; using SecureFolderFS.Core.Cryptography; +using SecureFolderFS.Core.Cryptography.Extensions; +using SecureFolderFS.Core.Cryptography.SecureStore; using SecureFolderFS.Core.DataModels; using SecureFolderFS.Core.VaultAccess; using SecureFolderFS.Sdk.EventArguments; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; -using SecureFolderFS.UI.Helpers; namespace SecureFolderFS.Maui.Platforms.iOS.ViewModels { @@ -24,11 +27,18 @@ public IOSBiometricCreationViewModel(IFolder vaultFolder, string vaultId, string /// protected override async Task ProvideCredentialsAsync(CancellationToken cancellationToken) { - using var keyMaterial = VaultHelpers.GenerateSecureKey(Constants.KeyTraits.ECIES_SHA256_AESGCM_STDX963_KEY_LENGTH); + using var keyMaterial = new ManagedKey(Constants.KeyTraits.ECIES_SHA256_AESGCM_STDX963_KEY_LENGTH); + RandomNumberGenerator.Fill(keyMaterial.Key); try { - var ciphertextKey = await EnrollAsync(VaultId, keyMaterial.Key, cancellationToken); + var keyResult = await EnrollAsync(VaultId, keyMaterial.Key, cancellationToken); + if (!keyResult.TryGetValue(out var ciphertextKey)) + { + Report(keyResult); + return; + } + var tcs = new TaskCompletionSource(); CredentialsProvided?.Invoke(this, new CredentialsProvidedEventArgs(keyMaterial.CreateCopy(), tcs)); await tcs.Task; @@ -38,7 +48,7 @@ protected override async Task ProvideCredentialsAsync(CancellationToken cancella await vaultWriter.WriteAuthenticationAsync($"{Id}{Core.Constants.Vault.Names.CONFIGURATION_EXTENSION}", new() { Capability = "supportsKeyProtection", - CiphertextKey = ciphertextKey.ToArray() + CiphertextKey = ciphertextKey.Key }, cancellationToken); } catch (Exception ex) diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricLoginViewModel.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricLoginViewModel.cs index a22336dfb..878030168 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricLoginViewModel.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricLoginViewModel.cs @@ -2,6 +2,7 @@ using SecureFolderFS.Core.DataModels; using SecureFolderFS.Core.VaultAccess; using SecureFolderFS.Sdk.EventArguments; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; namespace SecureFolderFS.Maui.Platforms.iOS.ViewModels @@ -24,16 +25,22 @@ protected override async Task ProvideCredentialsAsync(CancellationToken cancella { var vaultReader = new VaultReader(VaultFolder, StreamSerializer.Instance); var auth = await vaultReader.ReadAuthenticationAsync($"{Id}{Core.Constants.Vault.Names.CONFIGURATION_EXTENSION}", cancellationToken); - + if (auth?.CiphertextKey is null) { Report(Result.Failure(new ArgumentNullException(nameof(VaultProtectedKeyDataModel.CiphertextKey)))); return; } - + try { - var keyMaterial = await AcquireAsync(VaultId, auth.CiphertextKey, cancellationToken); + var keyResult = await AcquireAsync(VaultId, auth.CiphertextKey, cancellationToken); + if (!keyResult.TryGetValue(out var keyMaterial)) + { + Report(keyResult); + return; + } + var tcs = new TaskCompletionSource(); CredentialsProvided?.Invoke(this, new CredentialsProvidedEventArgs(keyMaterial, tcs)); await tcs.Task; diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricViewModel.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricViewModel.cs index 7e45cf851..c53fa6572 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricViewModel.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ViewModels/IOSBiometricViewModel.cs @@ -8,6 +8,7 @@ using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.ViewModels.Controls.Authentication; using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; using Security; namespace SecureFolderFS.Maui.Platforms.iOS.ViewModels @@ -55,72 +56,75 @@ public override Task RevokeAsync(string? id, CancellationToken cancellationToken KeyClass = SecKeyClass.Private, TokenID = SecTokenID.SecureEnclave }; - + // Remove the key from the keychain var status = SecKeyChain.Remove(query); _ = status; - + return Task.CompletedTask; } /// - public override async Task EnrollAsync(string id, byte[]? data, CancellationToken cancellationToken = default) + public override async Task> EnrollAsync(string id, byte[]? data, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(data); - + var alias = $"{KEY_ALIAS_PREFIX}{id}"; var privateKey = GetPrivateKey(alias); var context = new LAContext(); var (ok, evalErr) = await EvaluateAsync(context, LAPolicy.DeviceOwnerAuthenticationWithBiometrics, "AuthenticateForCredentials".ToLocalized()); if (!ok) throw new CryptographicException(evalErr?.LocalizedDescription ?? "Authentication failed."); - + privateKey ??= CreatePrivateKey(alias); var publicKey = privateKey.GetPublicKey() ?? throw new CryptographicException("Public key could not be retrieved.");; - return Encrypt(publicKey, data); + var encrypted = Encrypt(publicKey, data); + + return Result.Success(encrypted); } /// - public override async Task AcquireAsync(string id, byte[]? data, CancellationToken cancellationToken = default) + public override async Task> AcquireAsync(string id, byte[]? data, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(data); - + var alias = $"{KEY_ALIAS_PREFIX}{id}"; var privateKey = GetPrivateKey(alias); if (privateKey is null) throw new CryptographicException("Private key could not be found."); - - return await DecryptAsync(privateKey, data); + + var decrypted = await DecryptAsync(privateKey, data); + return Result.Success(decrypted); } - private static SecretKey Encrypt(SecKey publicKey, byte[] data) + private static ManagedKey Encrypt(SecKey publicKey, byte[] data) { using var plaintext = NSData.FromArray(data); using var ciphertext = publicKey.CreateEncryptedData(ALGORITHM, plaintext, out var error); - + if (ciphertext is null || error is not null) throw new CryptographicException($"Could not encrypt the data. {error?.LocalizedDescription}"); - + var ciphertextBuffer = ciphertext.ToArray(); - return SecureKey.TakeOwnership(ciphertextBuffer); + return ManagedKey.TakeOwnership(ciphertextBuffer); } - private static async Task DecryptAsync(SecKey privateKey, byte[] data) + private static async Task DecryptAsync(SecKey privateKey, byte[] data) { var ciphertext = NSData.FromArray(data); var plaintext = privateKey.CreateDecryptedData(ALGORITHM, ciphertext, out var error); if (plaintext is null || error is not null) throw new CryptographicException($"Could not decrypt the data. {error?.LocalizedDescription}"); - + var plaintextBuffer = plaintext.ToArray(); - return SecureKey.TakeOwnership(plaintextBuffer); + return ManagedKey.TakeOwnership(plaintextBuffer); } private static async Task<(bool ok, NSError? error)> EvaluateAsync(LAContext context, LAPolicy policy, string reason) { if (!context.CanEvaluatePolicy(policy, out var canErr)) return (false, canErr); - + var tcs = new TaskCompletionSource<(bool, NSError?)>(); context.EvaluatePolicy(policy, reason, (success, evalErr) => { @@ -166,7 +170,7 @@ private static SecKey CreatePrivateKey(string alias) // Query the keychain for a SecKey. var key = SecKeyChain.QueryAsConcreteType(query, out var result) as SecKey; - + // Return the SecKey based on the success status return result == SecStatusCode.Success ? key : null; } diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml index e23267923..5a3a35fdd 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml @@ -11,20 +11,26 @@ xmlns:vm2="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls.Authentication;assembly=SecureFolderFS.Sdk" xmlns:vm3="clr-namespace:SecureFolderFS.Sdk.ViewModels.Views.Credentials;assembly=SecureFolderFS.Sdk" x:Name="ThisPopup" + Margin="0" + Padding="0" BackgroundColor="Transparent" BindingContext="{x:Reference ThisPopup}"> + + + + + SelectedItem="{Binding RegisterViewModel.CurrentViewModel, Mode=TwoWay}" /> @@ -97,9 +103,11 @@ + HorizontalOptions="Fill" + VerticalOptions="Center"> @@ -114,11 +122,11 @@ Grid.Row="0" FontAttributes="Bold" FontSize="24" - Text="{Binding ViewModel.Title, Mode=OneWay}" /> + Text="{Binding ViewModel.Title, Mode=OneWay, Source={x:Reference ThisPopup}}" /> ShowAsync() if (ViewModel is null) return Result.Failure(null); - _ = await Shell.Current.CurrentPage.ShowPopupAsync(this); + await this.OverlayPopupAsync(); return Result.Success; } diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/PreviewRecoveryPopup.xaml b/src/Platforms/SecureFolderFS.Maui/Popups/PreviewRecoveryPopup.xaml index 0b61d4e4a..cf371315b 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/PreviewRecoveryPopup.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Popups/PreviewRecoveryPopup.xaml @@ -9,10 +9,8 @@ x:Name="ThisPopup" Margin="0" Padding="0" - Background="Transparent" BackgroundColor="Transparent" - BindingContext="{x:Reference ThisPopup}" - HorizontalOptions="FillAndExpand"> + BindingContext="{x:Reference ThisPopup}"> @@ -30,7 +28,12 @@ - + @@ -45,11 +48,11 @@ Grid.Row="0" FontAttributes="Bold" FontSize="24" - Text="{Binding ViewModel.Title, Mode=OneWay}" /> + Text="{Binding ViewModel.Title, Mode=OneWay, Source={x:Reference ThisPopup}}" /> diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/PreviewRecoveryPopup.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Popups/PreviewRecoveryPopup.xaml.cs index 67668f003..8182acac0 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/PreviewRecoveryPopup.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/Popups/PreviewRecoveryPopup.xaml.cs @@ -1,8 +1,8 @@ -using CommunityToolkit.Maui; -using CommunityToolkit.Maui.Extensions; using CommunityToolkit.Maui.Views; +using SecureFolderFS.Maui.Extensions; using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; using SecureFolderFS.UI.Utils; namespace SecureFolderFS.Maui.Popups @@ -18,13 +18,10 @@ public PreviewRecoveryPopup() public async Task ShowAsync() { if (ViewModel is null) - return Shared.Models.Result.Failure(null); + return Result.Failure(null); - _ = await Shell.Current.CurrentPage.ShowPopupAsync(this, new PopupOptions() - { - PageOverlayColor = Colors.Transparent - }); - return Shared.Models.Result.Success; + await this.OverlayPopupAsync(); + return Result.Success; } /// diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml b/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml index f27e8006b..37269e549 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml @@ -6,14 +6,21 @@ xmlns:tv="clr-namespace:CommunityToolkit.Maui.Views;assembly=CommunityToolkit.Maui" xmlns:uc="clr-namespace:SecureFolderFS.Maui.UserControls.Browser" x:Name="ThisPopup" + Margin="0" + Padding="0" BackgroundColor="Transparent" - BindingContext="{x:Reference ThisPopup}" - CanBeDismissedByTappingOutsideOfPopup="True"> + BindingContext="{x:Reference ThisPopup}"> + + + + + HorizontalOptions="Fill" + VerticalOptions="Center"> diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml.cs index d66d86a03..bb433384d 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml.cs @@ -1,5 +1,5 @@ -using CommunityToolkit.Maui.Extensions; using CommunityToolkit.Maui.Views; +using SecureFolderFS.Maui.Extensions; using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; @@ -20,7 +20,7 @@ public async Task ShowAsync() if (ViewModel is null) return Result.Failure(null); - _ = await Shell.Current.CurrentPage.ShowPopupAsync(this); + await this.OverlayPopupAsync(); return Result.Success; } diff --git a/src/Platforms/SecureFolderFS.Maui/Prompts/RecoveryPrompt.cs b/src/Platforms/SecureFolderFS.Maui/Prompts/RecoveryPrompt.cs index 9f684f3c8..d8e0d2a68 100644 --- a/src/Platforms/SecureFolderFS.Maui/Prompts/RecoveryPrompt.cs +++ b/src/Platforms/SecureFolderFS.Maui/Prompts/RecoveryPrompt.cs @@ -25,8 +25,8 @@ public async Task ShowAsync() "Cancel".ToLocalized()); var result = await ViewModel.RecoverAsync(default); - if (!result) - return Result.Failure(new CryptographicException(ViewModel.ErrorMessage)); + if (!result.Successful) + return result; return Result.Success; } diff --git a/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/iphone_dark.png b/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/iphone_dark.png new file mode 100644 index 000000000..68e54c9ca Binary files /dev/null and b/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/iphone_dark.png differ diff --git a/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/iphone_light.png b/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/iphone_light.png new file mode 100644 index 000000000..80569f745 Binary files /dev/null and b/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/iphone_light.png differ diff --git a/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/mbpro_dark.png b/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/mbpro_dark.png new file mode 100644 index 000000000..43fc5f58b Binary files /dev/null and b/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/mbpro_dark.png differ diff --git a/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/mbpro_light.png b/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/mbpro_light.png new file mode 100644 index 000000000..518c164b5 Binary files /dev/null and b/src/Platforms/SecureFolderFS.Maui/Resources/Images/Devices/mbpro_light.png differ diff --git a/src/Platforms/SecureFolderFS.Maui/Resources/Raw/auth_success.html b/src/Platforms/SecureFolderFS.Maui/Resources/Raw/auth_success.html index 362482e5e..d7e631334 100644 --- a/src/Platforms/SecureFolderFS.Maui/Resources/Raw/auth_success.html +++ b/src/Platforms/SecureFolderFS.Maui/Resources/Raw/auth_success.html @@ -51,7 +51,7 @@ justify-content: center; flex-direction: column; } - + .checkmark { width: 56px; height: 56px; @@ -63,7 +63,7 @@ box-shadow: inset 0px 0px 0px #7ac142; animation: fill .4s ease-in-out .4s forwards, .9s both; } - + .checkmark__circle { stroke-dasharray: 166; stroke-dashoffset: 166; @@ -73,21 +73,21 @@ fill: none; animation: stroke .6s cubic-bezier(0.650, 0.000, 0.450, 1.000) forwards; } - + .checkmark__check { transform-origin: 50% 50%; stroke-dasharray: 48; stroke-dashoffset: 48; animation: stroke .3s cubic-bezier(0.650, 0.000, 0.450, 1.000) .8s forwards; } - + @keyframes stroke { 100% { stroke-dashoffset: 0; } } - - + + @keyframes fill { 100% { box-shadow: inset 0px 0px 0px 30px #7ac142; @@ -112,4 +112,4 @@

Authentication successful

- \ No newline at end of file + diff --git a/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml b/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml index ccb9ffd47..c41035abc 100644 --- a/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml @@ -6,6 +6,7 @@ + diff --git a/src/Platforms/SecureFolderFS.Maui/SecureFolderFS.Maui.csproj b/src/Platforms/SecureFolderFS.Maui/SecureFolderFS.Maui.csproj index e79ee9a76..529038953 100644 --- a/src/Platforms/SecureFolderFS.Maui/SecureFolderFS.Maui.csproj +++ b/src/Platforms/SecureFolderFS.Maui/SecureFolderFS.Maui.csproj @@ -125,6 +125,7 @@ +
@@ -132,6 +133,9 @@ + + + @@ -176,6 +180,14 @@ IntroductionPage.xaml Code + + DeviceLinkCredentialsPage.xaml + Code + + + DeviceLinkRequestPage.xaml + Code + @@ -231,6 +243,16 @@ Designer + + Designer + + + Designer + + + + + diff --git a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/MauiOverlayService.cs b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/MauiOverlayService.cs index 7b52c7f12..c2606b6e7 100644 --- a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/MauiOverlayService.cs +++ b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/MauiOverlayService.cs @@ -2,6 +2,7 @@ using SecureFolderFS.Maui.Prompts; using SecureFolderFS.Maui.Sheets; using SecureFolderFS.Maui.Views; +using SecureFolderFS.Maui.Views.Modals.DeviceLink; using SecureFolderFS.Maui.Views.Modals.Settings; using SecureFolderFS.Maui.Views.Modals.Vault; using SecureFolderFS.Maui.Views.Modals.Wizard; @@ -37,6 +38,8 @@ protected override IOverlayControl GetOverlay(IViewable viewable) CredentialsOverlayViewModel => new CredentialsPopup(), StorableTypeOverlayViewModel => new StorableTypePrompt(), PreviewRecoveryOverlayViewModel => new PreviewRecoveryPopup(), + DeviceLinkRequestOverlayViewModel => new DeviceLinkRequestPage(navigation), + DeviceLinkCredentialsOverlayViewModel => new DeviceLinkCredentialsPage(navigation), _ => throw new ArgumentException("Unknown viewable type.", nameof(viewable)) }; diff --git a/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml b/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml index 3adfe68e7..5a3cfeae7 100644 --- a/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml @@ -4,6 +4,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:btsh="http://pluginmauibottomsheet.com" xmlns:l="using:SecureFolderFS.Maui.Localization" + xmlns:scm="http://plugin.segmentedControl.maui" xmlns:ucc="clr-namespace:SecureFolderFS.Maui.UserControls.Common" xmlns:uco="clr-namespace:SecureFolderFS.Maui.UserControls.Options" xmlns:vm="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls;assembly=SecureFolderFS.Sdk" @@ -34,7 +35,7 @@ SelectedItem="{Binding ViewModel.CurrentSortOption, Mode=TwoWay}" /> - + - - - - - + + + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/AccountBarControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/AccountBarControl.xaml new file mode 100644 index 000000000..297bcea85 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/AccountBarControl.xaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/AccountBarControl.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/AccountBarControl.xaml.cs new file mode 100644 index 000000000..6faab1f08 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/AccountBarControl.xaml.cs @@ -0,0 +1,47 @@ +using SecureFolderFS.Sdk.Ftp.ViewModels; +using SecureFolderFS.Sdk.GoogleDrive.ViewModels; + +namespace SecureFolderFS.Maui.UserControls +{ + public partial class AccountBarControl : ContentView + { + public AccountBarControl() + { + InitializeComponent(); + } + + public IDisposable? AccountViewModel + { + get => (IDisposable?)GetValue(AccountViewModelProperty); + set => SetValue(AccountViewModelProperty, value); + } + public static readonly BindableProperty AccountViewModelProperty = + BindableProperty.Create(nameof(AccountViewModel), typeof(IDisposable), typeof(AccountBarControl), + propertyChanged: static (bindable, value, newValue) => + { + if (bindable is not AccountBarControl accountBarControl) + return; + + switch (newValue) + { + case GDriveAccountViewModel gDriveViewModel: + { + accountBarControl.UserAvatar.IsVisible = gDriveViewModel.UserPhotoUri is not null; + accountBarControl.UserAvatar.ImageSource = gDriveViewModel.UserPhotoUri is null ? ImageSource.FromStream(static () => Stream.Null) : ImageSource.FromUri(gDriveViewModel.UserPhotoUri); + accountBarControl.UserTitle.Text = gDriveViewModel.UserDisplayName ?? string.Empty; + accountBarControl.UserSubtitle.Text = gDriveViewModel.UserEmail ?? string.Empty; + break; + } + + case FtpAccountViewModel ftpViewModel: + { + accountBarControl.UserAvatar.IsVisible = false; + accountBarControl.UserTitle.Text = ftpViewModel.UserName ?? string.Empty; + accountBarControl.UserSubtitle.Text = ftpViewModel.Address ?? string.Empty; + break; + } + } + }); + } +} + diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/ActivityButton.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/ActivityButton.xaml.cs index cea09a9cc..1dbfc3790 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/ActivityButton.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/ActivityButton.xaml.cs @@ -39,8 +39,8 @@ public string? Text set => SetValue(TextProperty, value); } public static readonly BindableProperty TextProperty = - BindableProperty.Create(nameof(Text), typeof(string), typeof(ActivityButton), propertyChanged: - static (bindable, _, _) => + BindableProperty.Create(nameof(Text), typeof(string), typeof(ActivityButton), + propertyChanged: static (bindable, _, _) => { if (bindable is not ActivityButton activityButton) return; @@ -57,8 +57,8 @@ public bool IsProgressing set => SetValue(IsProgressingProperty, value); } public static readonly BindableProperty IsProgressingProperty = - BindableProperty.Create(nameof(IsProgressing), typeof(bool), typeof(ActivityButton), false, propertyChanged: - static (bindable, _, _) => + BindableProperty.Create(nameof(IsProgressing), typeof(bool), typeof(ActivityButton), false, + propertyChanged: static (bindable, _, _) => { if (bindable is not ActivityButton activityButton) return; diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/BreadcrumbBar.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/BreadcrumbBar.xaml index 69904fafd..add9db412 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/BreadcrumbBar.xaml +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/BreadcrumbBar.xaml @@ -11,7 +11,6 @@ x:Name="ThisControl"> - diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml index c177e3d42..a42cf0175 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml @@ -27,7 +27,7 @@ - + @@ -41,19 +41,21 @@ VerticalOptions="Center" WidthRequest="32" /> - + @@ -209,31 +206,30 @@ Visibility="{x:Bind VaultViewModel.LastAccessDate, Mode=OneWay, Converter={StaticResource NullToVisibilityConverter}}" /> - + + + + Text="{x:Bind VaultViewModel.Title, Mode=OneWay}" + Visibility="{x:Bind IsRenaming, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> - - + + @@ -260,6 +256,16 @@ + + + + + + + + Visibility="{x:Bind VaultViewModel.IsUnlocked, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> @@ -297,7 +303,7 @@ diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs index 7579ebed6..2185ad714 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml.cs @@ -4,6 +4,7 @@ using Windows.ApplicationModel.DataTransfer; using Windows.System; using CommunityToolkit.Mvvm.Messaging; +using CommunityToolkit.WinUI; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; @@ -25,6 +26,9 @@ namespace SecureFolderFS.Uno.UserControls.InterfaceHost { public sealed partial class MainAppHostControl : UserControl, IRecipient, IRecipient +#if WINDOWS + , IRecipient +#endif { private bool _isInitialized; private bool _isCompactMode; // WINDOWS only @@ -55,6 +59,23 @@ public void Receive(VaultAddedMessage message) #endif } +#if WINDOWS + /// + public void Receive(VaultSelectionRequestedMessage message) + { + if (ViewModel?.VaultListViewModel is not { } vaultListViewModel) + return; + + // Find the vault item in the list + var vaultItem = vaultListViewModel.Items.FirstOrDefault(x => x.VaultViewModel.VaultModel.Equals(message.VaultModel)); + if (vaultItem is not null) + { + // Select the vault item which will trigger navigation + vaultListViewModel.SelectedItem = vaultItem; + } + } +#endif + private async Task NavigateToItem(VaultViewModel vaultViewModel) { await SetupNavigationAsync(); @@ -87,15 +108,23 @@ private async Task SetupNavigationAsync() } } - private void Rename_Click(object sender, RoutedEventArgs e) + private async void Rename_Click(object sender, RoutedEventArgs e) { - if (sender is not MenuFlyoutItem menuItem) - return; - - if (menuItem is not { DataContext: VaultListItemViewModel itemViewModel }) + if (sender is not MenuFlyoutItem { DataContext: VaultListItemViewModel itemViewModel }) return; itemViewModel.IsRenaming = true; + + // Get the container for the item from the NavigationView and find the TextBox + var container = Sidebar.ContainerFromMenuItem(itemViewModel); + if (container?.FindDescendant() is { } textBox) + { + // Wait for the TextBox to become visible after IsRenaming changes + await Task.Delay(50); + textBox.Focus(FocusState.Programmatic); + textBox.Text = itemViewModel.VaultViewModel.Title; + textBox.SelectAll(); + } } private async void RenameBox_KeyDown(object sender, KeyRoutedEventArgs e) @@ -120,16 +149,6 @@ private async void RenameBox_KeyDown(object sender, KeyRoutedEventArgs e) } } - private void RenameBox_Loaded(object sender, RoutedEventArgs e) - { - if (sender is not TextBox { DataContext: VaultListItemViewModel itemViewModel } textBox) - return; - - textBox.Focus(FocusState.Programmatic); - textBox.Text = itemViewModel.VaultViewModel.Title; - textBox.SelectAll(); - } - private void RenameBox_LostFocus(object sender, RoutedEventArgs e) { if (sender is not TextBox { DataContext: VaultListItemViewModel itemViewModel }) @@ -142,6 +161,9 @@ private async void MainAppHostControl_Loaded(object sender, RoutedEventArgs e) { WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); +#if WINDOWS + WeakReferenceMessenger.Default.Register(this); +#endif await SetupNavigationAsync(); } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/NoVaultsAppHostControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/NoVaultsAppHostControl.xaml index 29152ddc2..1ff4de517 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/NoVaultsAppHostControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/NoVaultsAppHostControl.xaml @@ -6,13 +6,8 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:l="using:SecureFolderFS.Uno.Localization" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vc="using:SecureFolderFS.Uno.ValueConverters" mc:Ignorable="d"> - - - - + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/DebugWindowRootControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/DebugWindowRootControl.xaml.cs index 1032fa9b7..8e97b9320 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/DebugWindowRootControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/DebugWindowRootControl.xaml.cs @@ -21,6 +21,7 @@ private void NavigationView_SelectionChanged(NavigationView sender, NavigationVi { 0 => typeof(DebugAppControlPage), 1 => typeof(DebugFileSystemLogPage), + 2 => typeof(DebugPhoneLinkPage), _ => throw new ArgumentOutOfRangeException(nameof(tag)) }; diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml index 01dc86059..f003f7ece 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml @@ -33,12 +33,13 @@ - + - + PrimaryTitle="SecureFolderFS" + SecondaryTitle="Release Candidate 1" /> + @@ -98,5 +99,15 @@ + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs index 1f6fadd43..d88363673 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs @@ -2,36 +2,29 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using CommunityToolkit.Mvvm.Messaging; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -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.Root; using SecureFolderFS.Sdk.ViewModels.Views.Host; -using SecureFolderFS.Shared; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; using SecureFolderFS.UI.Helpers; using SecureFolderFS.Uno.Helpers; using Uno.UI; +#if WINDOWS +using CommunityToolkit.Mvvm.Messaging; +using SecureFolderFS.Sdk.Messages; +#endif // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. namespace SecureFolderFS.Uno.UserControls.InterfaceRoot { - [INotifyPropertyChanged] public sealed partial class MainWindowRootControl : UserControl { - public INavigationService RootNavigationService { get; } = DI.Service(); - - public SynchronizationContext? Context { get; } - public bool IsDebugging { get; } = #if DEBUG Debugger.IsAttached; @@ -45,11 +38,11 @@ public MainViewModel? ViewModel set => DataContext = value; } - public MainWindowRootControl() + public MainWindowRootControl(MainViewModel mainViewModel) { InitializeComponent(); - Context = SynchronizationContext.Current; - ViewModel = new(new VaultCollectionModel()); + App.Instance.MainWindowSynchronizationContext = SynchronizationContext.Current; + ViewModel = mainViewModel; } private void MainWindowRootControl_Loaded(object sender, RoutedEventArgs e) @@ -57,7 +50,7 @@ private void MainWindowRootControl_Loaded(object sender, RoutedEventArgs e) if (OperatingSystem.IsMacCatalyst()) RootGrid.Margin = new(0, 37, 0, 0); - RootNavigationService.SetupNavigation(Navigation); + ViewModel.RootNavigationService.SetupNavigation(Navigation); _ = EnsureRootAsync(); } @@ -92,13 +85,16 @@ private async Task EnsureRootAsync() if (!ViewModel.VaultCollectionModel.IsEmpty()) // Has vaults { // Show main app screen - await RootNavigationService.TryNavigateAsync(() => new MainHostViewModel(ViewModel.VaultCollectionModel), false); + await ViewModel.RootNavigationService.TryNavigateAsync(() => new MainHostViewModel(ViewModel.VaultListViewModel, ViewModel.VaultCollectionModel), false); } else // Doesn't have vaults { // Show no vaults screen - await RootNavigationService.TryNavigateAsync(() => new EmptyHostViewModel(RootNavigationService, ViewModel.VaultCollectionModel), false); + await ViewModel.RootNavigationService.TryNavigateAsync(() => new EmptyHostViewModel(ViewModel.VaultListViewModel, ViewModel.RootNavigationService, ViewModel.VaultCollectionModel), false); } + + // Signal that the main window has finished initializing + App.Instance?.MainWindowInitialized.TrySetResult(); } private void DebugButton_Click(object sender, RoutedEventArgs e) @@ -141,11 +137,13 @@ private void MenuShowApp_Click(object sender, RoutedEventArgs e) private void MenuLockAll_Click(object sender, RoutedEventArgs e) { +#if WINDOWS if (ViewModel is null) return; foreach (var item in ViewModel.VaultCollectionModel) WeakReferenceMessenger.Default.Send(new VaultLockRequestedMessage(item)); +#endif } } } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml new file mode 100644 index 000000000..ef0fa5d96 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml.cs new file mode 100644 index 000000000..141d7c8ff --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/VaultPreviewRootControl.xaml.cs @@ -0,0 +1,24 @@ +using Microsoft.UI.Xaml.Controls; +using SecureFolderFS.Sdk.ViewModels.Views.Root; +using SecureFolderFS.Shared.Extensions; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace SecureFolderFS.Uno.UserControls.InterfaceRoot +{ + public sealed partial class VaultPreviewRootControl : UserControl + { + public VaultPreviewViewModel? ViewModel + { + get => DataContext.TryCast(); + set => DataContext = value; + } + + public VaultPreviewRootControl(VaultPreviewViewModel viewModel) + { + InitializeComponent(); + ViewModel = viewModel; + } + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AgreementScreen.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AgreementScreen.xaml deleted file mode 100644 index 336554db0..000000000 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AgreementScreen.xaml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - Please read and agree to our Privacy Policy - and Terms of Service. - - - diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs index a377f03a6..18eeba9cd 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs @@ -1,11 +1,20 @@ +using System; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; +using SecureFolderFS.UI; +using SecureFolderFS.UI.Enums; using SecureFolderFS.UI.Utils; using SecureFolderFS.Uno.Extensions; +using SecureFolderFS.Uno.Helpers; +using SecureFolderFS.Uno.UserControls.InterfaceRoot; +using Windows.System; // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. @@ -14,6 +23,9 @@ namespace SecureFolderFS.Uno.UserControls.Introduction { public sealed partial class IntroductionControl : UserControl, IOverlayControl { + private Grid? _overlayContainer; + private TitleBarControl? _customTitleBar; + public IntroductionOverlayViewModel? ViewModel { get => DataContext.TryCast(); @@ -26,16 +38,110 @@ public IntroductionControl() } /// - public Task ShowAsync() => ViewModel?.TaskCompletion.Task ?? Task.FromResult(Result.Failure(null)); + public async Task ShowAsync() + { + if (ViewModel is null) + return Result.Failure(null); + + if (ViewModel.TaskCompletion.Task.IsCompleted) + return ViewModel.TaskCompletion.Task.Result; + + if (App.Instance?.MainWindow?.Content is not MainWindowRootControl { OverlayContainer: var overlayContainer, CustomTitleBar: var customTitleBar }) + return Result.Failure(null); + + _customTitleBar = customTitleBar; + _overlayContainer = overlayContainer; + if (_overlayContainer is null) + return Result.Failure(null); + + // Add this control to the overlay container + _overlayContainer.Children.Add(this); + await Task.Delay(300); + + // Set the visibility of the overlay container + _overlayContainer.Visibility = Visibility.Visible; + RootGrid.Opacity = 0; + + if (_customTitleBar is not null) + _customTitleBar.Opacity = 0d; + + // Play the show animation + await ShowOverlayStoryboard.BeginAsync(); + ShowOverlayStoryboard.Stop(); + RootGrid.Opacity = 1; + + // Wait for the overlay to be closed + return await ViewModel.TaskCompletion.Task; + } /// - public void SetView(IViewable viewable) => ViewModel = (IntroductionOverlayViewModel)viewable; + public void SetView(IViewable viewable) + { + ViewModel = (IntroductionOverlayViewModel)viewable; + if (ViewModel is { SlidesCount: < 0 }) + ViewModel.SlidesCount = 3; + } /// - public Task HideAsync() + [RelayCommand] + public async Task HideAsync() { - ViewModel?.TaskCompletion.SetResult(ContentDialogResult.None.ParseOverlayOption()); - return Task.CompletedTask; + // Play the hide animation + await HideOverlayStoryboard.BeginAsync(); + HideOverlayStoryboard.Stop(); + + // Hide and clean up the overlay container + if (_overlayContainer is not null) + { + if (_customTitleBar is not null) + _customTitleBar.Opacity = 1d; + + _overlayContainer.Children.Remove(this); + _overlayContainer.Visibility = Visibility.Collapsed; + _overlayContainer = null; + } + + ViewModel?.TaskCompletion.SetResult(Result.Success); + } + + private async void BackgroundWebView_Loaded(object sender, RoutedEventArgs e) + { + var htmlString = Constants.Introduction.BACKGROUND_WEBVIEW + .Replace("c_bg", UnoThemeHelper.Instance.CurrentTheme switch + { + ThemeType.Light => "vec3(0.80, 0.86, 0.92)", + _ => "vec3(0.00, 0.08, 0.15)" + }) + .Replace("c_wave", UnoThemeHelper.Instance.CurrentTheme switch + { + ThemeType.Light => "vec3(0.10, 0.42, 0.75)", + _ => "vec3(0.090, 0.569, 1.0)" + }); + + await BackgroundWebView.EnsureCoreWebView2Async(); + BackgroundWebView.NavigateToString(htmlString); + } + + private void IntroductionControl_KeyDown(object sender, KeyRoutedEventArgs e) + { + switch (e.Key) + { + case VirtualKey.Right: + { + ViewModel?.Next(); + e.Handled = true; + + break; + } + + case VirtualKey.Left: + { + ViewModel?.Previous(); + e.Handled = true; + + break; + } + } } } } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/WelcomeScreen.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/WelcomeScreen.xaml index 66bc0f475..a389e3c8f 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/WelcomeScreen.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/WelcomeScreen.xaml @@ -3,39 +3,63 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:ext="using:SecureFolderFS.Uno.Extensions" + xmlns:l="using:SecureFolderFS.Uno.Localization" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> - + - + - + VerticalAlignment="Bottom"> + + + + + + + - + + + + + + TextWrapping="Wrap"> + By continuing, you agree to our Privacy Policy + and Terms of Service diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/LoginControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/LoginControl.xaml index 74c6fb157..7cbf4ad99 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/LoginControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/LoginControl.xaml @@ -9,8 +9,10 @@ xmlns:ts="using:SecureFolderFS.Uno.TemplateSelectors" xmlns:uc="using:SecureFolderFS.Uno.UserControls" xmlns:vm="using:SecureFolderFS.UI.ViewModels.Authentication" - xmlns:vm2="using:SecureFolderFS.Uno.ViewModels" - xmlns:vm3="using:SecureFolderFS.Sdk.ViewModels.Controls.Authentication" + xmlns:vm2="using:SecureFolderFS.Uno.ViewModels.WindowsHello" + xmlns:vm3="using:SecureFolderFS.Uno.ViewModels.YubiKey" + xmlns:vm4="using:SecureFolderFS.Uno.ViewModels.DeviceLink" + xmlns:vm5="using:SecureFolderFS.Sdk.ViewModels.Controls.Authentication" x:Name="ThisLoginControl" mc:Ignorable="d"> @@ -48,10 +50,7 @@ Glyph="" /> - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + - + @@ -185,7 +185,10 @@ - + diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml.cs index 6d1d5ca26..b44770ced 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml.cs @@ -80,6 +80,7 @@ private async Task UpdateAdapterStatus(CancellationToken cancellationToken = def break; } +#if WINDOWS case Core.Dokany.Constants.FileSystem.FS_ID: case Core.WinFsp.Constants.FileSystem.FS_ID: { @@ -112,6 +113,7 @@ private async Task UpdateAdapterStatus(CancellationToken cancellationToken = def break; } +#endif default: ViewModel.BannerViewModel.FileSystemInfoBar.IsOpen = false; diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PrivacySettingsPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PrivacySettingsPage.xaml index 0e899c1f3..137f73c30 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PrivacySettingsPage.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PrivacySettingsPage.xaml @@ -5,6 +5,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:l="using:SecureFolderFS.Uno.Localization" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:uc="using:SecureFolderFS.Uno.UserControls" xmlns:ucab="using:SecureFolderFS.Uno.UserControls.ActionBlocks" mc:Ignorable="d"> @@ -14,32 +15,45 @@ + + - + + + + - - - - - - - - + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultDashboardPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultDashboardPage.xaml index a2cca397e..22efa60e6 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultDashboardPage.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultDashboardPage.xaml @@ -47,6 +47,22 @@ + + + + + + + @@ -72,7 +89,7 @@ Width="32" Height="32" Margin="0,0,16,0" - Padding="8" + Padding="4" AutomationProperties.Name="{l:ResourceString Rid=Back}" Background="Transparent" BorderThickness="0" @@ -99,6 +116,12 @@ + + + diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultDashboardPage.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultDashboardPage.xaml.cs index 16204bfcc..acd336010 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultDashboardPage.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultDashboardPage.xaml.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.UI.Xaml; @@ -8,6 +10,7 @@ using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.UI.Helpers; +using SecureFolderFS.UI.Utils; using SecureFolderFS.Uno.Extensions; // To learn more about WinUI, the WinUI project structure, @@ -18,7 +21,6 @@ namespace SecureFolderFS.Uno.Views.Vault /// /// An empty page that can be used on its own or navigated to within a Frame. /// - [INotifyPropertyChanged] public sealed partial class VaultDashboardPage : Page { private bool _isLoaded; @@ -26,7 +28,7 @@ public sealed partial class VaultDashboardPage : Page public VaultDashboardViewModel? ViewModel { get => DataContext.TryCast(); - set { DataContext = value; OnPropertyChanged(); } + set { DataContext = value; } } public VaultDashboardPage() @@ -109,6 +111,49 @@ private async void DashboardNavigation_NavigationChanged(object? sender, IViewDe HideBackButtonStoryboard.Stop(); GoBack.Visibility = Visibility.Collapsed; } + + await Task.Delay(100); + if ((Navigation.Content as Frame)?.Content is not IEmbeddedControlContent embeddedContent) + { + await HideEmbedAsync(); + EmbeddedContentControl.Content = null; + return; + } + + if (embeddedContent.EmbeddedContent is { } newEmbed) + { + // Hide existing embed + if (EmbeddedContentControl.Content is not null) + await HideEmbedAsync(); + + // Show new embed + EmbeddedContentControl.Content = newEmbed; + await ShowEmbedAsync(); + } + else + { + if (EmbeddedContentControl.Content is not null) + { + // Hide existing embed + await HideEmbedAsync(); + EmbeddedContentControl.Content = null; + } + } + } + + private async Task HideEmbedAsync() + { + EmbeddedContentControl.Visibility = Visibility.Visible; + await HideEmbedStoryboard.BeginAsync(); + EmbeddedContentControl.Visibility = Visibility.Collapsed; + HideEmbedStoryboard.Stop(); + } + + private async Task ShowEmbedAsync() + { + EmbeddedContentControl.Visibility = Visibility.Visible; + await ShowEmbedStoryboard.BeginAsync(); + ShowEmbedStoryboard.Stop(); } } } diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultHealthPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultHealthPage.xaml index 1aab08f02..d25f3ceb8 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultHealthPage.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultHealthPage.xaml @@ -18,6 +18,7 @@ - + @@ -74,6 +75,7 @@ - + @@ -143,16 +145,54 @@ - - - + + + + + + + + + + + + + + + + + + + + @@ -173,6 +213,7 @@ - + - + @@ -52,67 +89,22 @@ VerticalContentAlignment="Stretch" Content="{Binding}"> - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultOverviewPage.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultOverviewPage.xaml.cs index 29ffbf54d..de8259faf 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultOverviewPage.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultOverviewPage.xaml.cs @@ -1,8 +1,10 @@ using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Navigation; using SecureFolderFS.Sdk.ViewModels.Views.Vault; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.UI.Utils; // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. @@ -13,13 +15,16 @@ namespace SecureFolderFS.Uno.Views.Vault /// An empty page that can be used on its own or navigated to within a Frame. ///
[INotifyPropertyChanged] - public sealed partial class VaultOverviewPage : Page + public sealed partial class VaultOverviewPage : Page, IEmbeddedControlContent { public VaultOverviewViewModel? ViewModel { get => DataContext.TryCast(); set { DataContext = value; OnPropertyChanged(); } } + + /// + public object? EmbeddedContent { get => field ??= CreateEmbeddedContent(); } public VaultOverviewPage() { @@ -34,5 +39,19 @@ protected override void OnNavigatedTo(NavigationEventArgs e) base.OnNavigatedTo(e); } + + private DependencyObject? CreateEmbeddedContent() + { + if (Resources.TryGetValue("WidgetReorderButtonTemplate", out var resource) && resource is DataTemplate template) + { + var content = template.LoadContent(); + if (content is FrameworkElement element) + element.DataContext = ViewModel; + + return content; + } + + return null; + } } } diff --git a/src/Sdk/SecureFolderFS.Sdk.Ftp/DataModels/FtpAccountDataModel.cs b/src/Sdk/SecureFolderFS.Sdk.Ftp/DataModels/FtpAccountDataModel.cs index a004e4e3c..07f3474cb 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Ftp/DataModels/FtpAccountDataModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk.Ftp/DataModels/FtpAccountDataModel.cs @@ -11,7 +11,7 @@ public sealed record FtpAccountDataModel(string? AccountId, string? DataSourceTy public required string? Address { get; init; } [JsonPropertyName("username")] - public required string? Username { get; init; } + public required string? UserName { get; init; } [JsonPropertyName("password")] public required string? Password { get; init; } diff --git a/src/Sdk/SecureFolderFS.Sdk.Ftp/Storage/FtpFile.cs b/src/Sdk/SecureFolderFS.Sdk.Ftp/Storage/FtpFile.cs index 628fa6511..12eb5c30f 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Ftp/Storage/FtpFile.cs +++ b/src/Sdk/SecureFolderFS.Sdk.Ftp/Storage/FtpFile.cs @@ -24,15 +24,11 @@ public async Task OpenStreamAsync(FileAccess accessMode, CancellationTok var ftpStream = accessMode switch { FileAccess.Read => await ftpClient.OpenRead(Id, token: cancellationToken), - FileAccess.Write => await ftpClient.OpenWrite(Id, token: cancellationToken), + FileAccess.Write => await ftpClient.OpenWrite(Id, FtpDataType.Binary, false, token: cancellationToken), _ => throw new NotSupportedException($"The {nameof(FileAccess)} '{accessMode}' is not supported on an FTP stream."), }; - var fileSize = await ftpClient.GetFileSize(Id, -1L, cancellationToken); - if (fileSize < 0L) - throw new UnauthorizedAccessException("Cannot read the file size."); - - return new LengthSupportedFtpStream(ftpStream, fileSize); + return ftpStream; } } } diff --git a/src/Sdk/SecureFolderFS.Sdk.Ftp/ViewModels/FtpAccountViewModel.cs b/src/Sdk/SecureFolderFS.Sdk.Ftp/ViewModels/FtpAccountViewModel.cs index 8a3dc36ff..e9a9af093 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Ftp/ViewModels/FtpAccountViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk.Ftp/ViewModels/FtpAccountViewModel.cs @@ -20,7 +20,7 @@ public sealed partial class FtpAccountViewModel : AccountViewModel private AsyncFtpClient? _ftpClient; [ObservableProperty] private string? _Address; - [ObservableProperty] private string? _Username; + [ObservableProperty] private string? _UserName; [ObservableProperty] private string? _Password; /// @@ -42,7 +42,7 @@ protected override async Task ConnectFromUserInputAsync(CancellationTok if (_ftpClient is not null) return new FtpFolder(_ftpClient, "/", string.Empty); - return await ConnectAsync(Address, Username, Password, cancellationToken); + return await ConnectAsync(Address, UserName, Password, cancellationToken); } /// @@ -65,7 +65,12 @@ protected override async Task ConnectFromDataModelAsync(CancellationTok if (ftpAccountDataModel is null) throw new ArgumentException("Data cannot be deserialized."); - return await ConnectAsync(ftpAccountDataModel.Address, ftpAccountDataModel.Username, ftpAccountDataModel.Password, cancellationToken); + // Update properties from the data model (except password for security reasons) + Address = ftpAccountDataModel.Address; + UserName = ftpAccountDataModel.UserName; + + // Connect using the data model + return await ConnectAsync(ftpAccountDataModel.Address, ftpAccountDataModel.UserName, ftpAccountDataModel.Password, cancellationToken); } /// @@ -96,10 +101,10 @@ protected override async Task CheckConnectionAsync(CancellationToken cance protected override async Task SaveAccountAsync(CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(propertyStore); - var dataModel = new FtpAccountDataModel(AccountId, DataSourceType, Username) + var dataModel = new FtpAccountDataModel(AccountId, DataSourceType, UserName) { Address = Address, - Username = Username, + UserName = UserName, Password = Password }; @@ -124,9 +129,10 @@ private async Task ConnectAsync(string? address, string? username, stri _ftpClient = new AsyncFtpClient(uri.Host, username, password ?? string.Empty, uri.Port, config); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(3000)); + timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(6000)); await _ftpClient.Connect(timeoutCts.Token); IsConnected = true; + Password = null; return new FtpFolder(_ftpClient, "/", string.Empty); } @@ -139,13 +145,7 @@ private async Task ConnectAsync(string? address, string? username, stri private void UpdateInputValidation() { - if (string.IsNullOrEmpty(Address) || string.IsNullOrEmpty(Username)) - { - IsInputFilled = false; - return; - } - - IsInputFilled = Address.StartsWith("http", StringComparison.OrdinalIgnoreCase); + IsInputFilled = !string.IsNullOrEmpty(Address) && !string.IsNullOrEmpty(UserName); } partial void OnAddressChanged(string? value) @@ -154,7 +154,7 @@ partial void OnAddressChanged(string? value) UpdateInputValidation(); } - partial void OnUsernameChanged(string? value) + partial void OnUserNameChanged(string? value) { _ = value; UpdateInputValidation(); diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/SecureFolderFS.Sdk.GoogleDrive.csproj b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/SecureFolderFS.Sdk.GoogleDrive.csproj index 5ba82929a..31de52a6c 100644 --- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/SecureFolderFS.Sdk.GoogleDrive.csproj +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/SecureFolderFS.Sdk.GoogleDrive.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveFile.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveFile.cs index d9c216dfa..113903c94 100644 --- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveFile.cs +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveFile.cs @@ -4,7 +4,9 @@ using System.Threading.Tasks; using Google.Apis.Drive.v3; using OwlCore.Storage; +using SecureFolderFS.Sdk.GoogleDrive.Storage.StorageProperties; using SecureFolderFS.Sdk.GoogleDrive.Streams; +using SecureFolderFS.Storage.StorageProperties; namespace SecureFolderFS.Sdk.GoogleDrive.Storage { @@ -24,16 +26,17 @@ public GDriveFile(DriveService driveService, string mimeType, string id, string /// public virtual async Task OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default) { - var request = DriveService.Files.Get(DetachedId); - request.Fields = "size"; - var file = await request.ExecuteAsync(cancellationToken); - var size = file.Size ?? 0L; - switch (accessMode) { case FileAccess.Read: { - return new GoogleDriveReadStream(DriveService, DetachedId, size); + var request = DriveService.Files.Get(DetachedId); + request.Fields = "size"; + var file = await request.ExecuteAsync(cancellationToken); + var size = file.Size ?? 0L; + + // Reuse the request object for the stream to avoid creating another one + return new GoogleDriveReadStream(request, size); } case FileAccess.Write: @@ -45,5 +48,12 @@ public virtual async Task OpenStreamAsync(FileAccess accessMode, Cancell throw new NotSupportedException($"Access mode {accessMode} is not supported."); } } + + /// + public override Task GetPropertiesAsync() + { + properties ??= new GDriveFileProperties(this, DriveService); + return Task.FromResult(properties); + } } } \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveFolder.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveFolder.cs index 5eebdffa2..e34b2931b 100644 --- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveFolder.cs +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveFolder.cs @@ -6,7 +6,9 @@ using System.Threading.Tasks; using Google.Apis.Drive.v3; using OwlCore.Storage; +using SecureFolderFS.Sdk.GoogleDrive.Storage.StorageProperties; using SecureFolderFS.Storage.Renamable; +using SecureFolderFS.Storage.StorageProperties; using File = Google.Apis.Drive.v3.Data.File; namespace SecureFolderFS.Sdk.GoogleDrive.Storage @@ -249,5 +251,12 @@ public virtual async Task CreateFileAsync(string name, bool overwrit targetFile.Name, this); } + + /// + public override Task GetPropertiesAsync() + { + properties ??= new GDriveFolderProperties(this, DriveService); + return Task.FromResult(properties); + } } } \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveStorable.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveStorable.cs index a44f9fce4..3adfefbdc 100644 --- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveStorable.cs +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/GDriveStorable.cs @@ -3,11 +3,14 @@ using System.Threading.Tasks; using Google.Apis.Drive.v3; using OwlCore.Storage; +using SecureFolderFS.Storage.StorageProperties; namespace SecureFolderFS.Sdk.GoogleDrive.Storage { - public abstract class GDriveStorable : IStorableChild + public abstract class GDriveStorable : IStorableChild, IStorableProperties { + protected IBasicProperties? properties; + /// /// Gets the ID of the object that can exist independently of the parent context. /// @@ -48,6 +51,9 @@ protected GDriveStorable(DriveService driveService, string id, string name, IFol return Task.FromResult(ParentFolder); } + /// + public abstract Task GetPropertiesAsync(); + /// /// Combines a parent path and a child path into a single path string. /// diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/StorageProperties/GDriveFileProperties.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/StorageProperties/GDriveFileProperties.cs new file mode 100644 index 000000000..117066e80 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/StorageProperties/GDriveFileProperties.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Google.Apis.Drive.v3; +using OwlCore.Storage; +using SecureFolderFS.Storage.StorageProperties; + +namespace SecureFolderFS.Sdk.GoogleDrive.Storage.StorageProperties +{ + /// + public sealed class GDriveFileProperties : ISizeProperties, IDateProperties, IBasicProperties + { + private readonly GDriveFile _file; + private readonly DriveService _driveService; + + public GDriveFileProperties(GDriveFile file, DriveService driveService) + { + _file = file; + _driveService = driveService; + } + + /// + public async Task?> GetSizeAsync(CancellationToken cancellationToken = default) + { + var request = _driveService.Files.Get(_file.DetachedId); + request.Fields = "size"; + var file = await request.ExecuteAsync(cancellationToken); + var sizeProperty = file.Size.HasValue ? new GenericProperty(file.Size.Value) : null; + + return sizeProperty; + } + + /// + public async Task> GetDateCreatedAsync(CancellationToken cancellationToken = default) + { + var request = _driveService.Files.Get(_file.DetachedId); + request.Fields = "createdTime"; + var file = await request.ExecuteAsync(cancellationToken); + var dateProperty = new GenericProperty(file.CreatedTimeDateTimeOffset?.DateTime ?? DateTime.MinValue); + + return dateProperty; + } + + /// + public async Task> GetDateModifiedAsync(CancellationToken cancellationToken = default) + { + var request = _driveService.Files.Get(_file.DetachedId); + request.Fields = "modifiedTime"; + var file = await request.ExecuteAsync(cancellationToken); + var dateProperty = new GenericProperty(file.ModifiedTimeDateTimeOffset?.DateTime ?? DateTime.MinValue); + + return 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/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/StorageProperties/GDriveFolderProperties.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/StorageProperties/GDriveFolderProperties.cs new file mode 100644 index 000000000..db3c318d5 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Storage/StorageProperties/GDriveFolderProperties.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Google.Apis.Drive.v3; +using OwlCore.Storage; +using SecureFolderFS.Storage.StorageProperties; + +namespace SecureFolderFS.Sdk.GoogleDrive.Storage.StorageProperties +{ + /// + public sealed class GDriveFolderProperties : IDateProperties, IBasicProperties + { + private readonly GDriveFolder _folder; + private readonly DriveService _driveService; + + public GDriveFolderProperties(GDriveFolder folder, DriveService driveService) + { + _folder = folder; + _driveService = driveService; + } + + /// + public async Task> GetDateCreatedAsync(CancellationToken cancellationToken = default) + { + var request = _driveService.Files.Get(_folder.DetachedId); + request.Fields = "createdTime"; + var file = await request.ExecuteAsync(cancellationToken); + var dateProperty = new GenericProperty(file.CreatedTimeDateTimeOffset?.DateTime ?? DateTime.MinValue); + + return dateProperty; + } + + /// + public async Task> GetDateModifiedAsync(CancellationToken cancellationToken = default) + { + var request = _driveService.Files.Get(_folder.DetachedId); + request.Fields = "modifiedTime"; + var file = await request.ExecuteAsync(cancellationToken); + var dateProperty = new GenericProperty(file.ModifiedTimeDateTimeOffset?.DateTime ?? DateTime.MinValue); + + return 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/Sdk/SecureFolderFS.Sdk.GoogleDrive/Streams/GoogleDriveReadStream.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Streams/GoogleDriveReadStream.cs index 64033a9e8..1fd75a673 100644 --- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Streams/GoogleDriveReadStream.cs +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Streams/GoogleDriveReadStream.cs @@ -8,9 +8,8 @@ namespace SecureFolderFS.Sdk.GoogleDrive.Streams { internal sealed class GoogleDriveReadStream : Stream { - private readonly DriveService _service; - private readonly string _fileId; private readonly long _fileSize; + private readonly FilesResource.GetRequest _cachedRequest; private long _position; private bool _disposed; @@ -40,9 +39,13 @@ public override long Position } public GoogleDriveReadStream(DriveService service, string fileId, long fileSize) + : this(service.Files.Get(fileId), fileSize) { - _service = service; - _fileId = fileId; + } + + public GoogleDriveReadStream(FilesResource.GetRequest request, long fileSize) + { + _cachedRequest = request; _fileSize = fileSize; _position = 0; } @@ -50,21 +53,18 @@ public GoogleDriveReadStream(DriveService service, string fileId, long fileSize) /// public override int Read(byte[] buffer, int offset, int count) { - if (_disposed) - throw new ObjectDisposedException(nameof(GoogleDriveReadStream)); - + ObjectDisposedException.ThrowIf(_disposed, this); if (_position >= _fileSize) return 0; var bytesToRead = (int)Math.Min(count, _fileSize - _position); - var request = _service.Files.Get(_fileId); // Set range header for partial download var endByte = _position + bytesToRead - 1; using var memoryStream = new MemoryStream(); var rangeHeader = new System.Net.Http.Headers.RangeHeaderValue(_position, endByte); - _ = request.DownloadRange(memoryStream, rangeHeader); + _ = _cachedRequest.DownloadRange(memoryStream, rangeHeader); memoryStream.Position = 0; var read = memoryStream.Read(buffer.AsSpan(offset, count)); @@ -76,21 +76,18 @@ public override int Read(byte[] buffer, int offset, int count) /// public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - if (_disposed) - throw new ObjectDisposedException(nameof(GoogleDriveReadStream)); - + ObjectDisposedException.ThrowIf(_disposed, this); if (_position >= _fileSize) return 0; var bytesToRead = (int)Math.Min(count, _fileSize - _position); - var request = _service.Files.Get(_fileId); // Set range header for partial download var endByte = _position + bytesToRead - 1; using var memoryStream = new MemoryStream(); var rangeHeader = new System.Net.Http.Headers.RangeHeaderValue(_position, endByte); - _ = await request.DownloadRangeAsync(memoryStream, rangeHeader, cancellationToken); + _ = await _cachedRequest.DownloadRangeAsync(memoryStream, rangeHeader, cancellationToken); memoryStream.Position = 0; var read = await memoryStream.ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None); @@ -102,9 +99,7 @@ public override async Task ReadAsync(byte[] buffer, int offset, int count, /// public override long Seek(long offset, SeekOrigin origin) { - if (_disposed) - throw new ObjectDisposedException(nameof(GoogleDriveReadStream)); - + ObjectDisposedException.ThrowIf(_disposed, this); var newPosition = origin switch { SeekOrigin.Begin => offset, diff --git a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Streams/GoogleDriveWriteStream.cs b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Streams/GoogleDriveWriteStream.cs index ffe467d2a..6853ca876 100644 --- a/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Streams/GoogleDriveWriteStream.cs +++ b/src/Sdk/SecureFolderFS.Sdk.GoogleDrive/Streams/GoogleDriveWriteStream.cs @@ -48,27 +48,21 @@ public GoogleDriveWriteStream(DriveService service, string fileId, string name, /// public override void Write(byte[] buffer, int offset, int count) { - if (_disposed) - throw new ObjectDisposedException(nameof(GoogleDriveWriteStream)); - + ObjectDisposedException.ThrowIf(_disposed, this); _memoryStreamBuffer.Write(buffer, offset, count); } /// public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - if (_disposed) - throw new ObjectDisposedException(nameof(GoogleDriveWriteStream)); - + ObjectDisposedException.ThrowIf(_disposed, this); return _memoryStreamBuffer.WriteAsync(buffer, offset, count, cancellationToken); } /// public override void Flush() { - if (_disposed) - throw new ObjectDisposedException(nameof(GoogleDriveWriteStream)); - + ObjectDisposedException.ThrowIf(_disposed, this); if (_memoryStreamBuffer.Length == 0) return; @@ -85,9 +79,7 @@ public override void Flush() /// public override async Task FlushAsync(CancellationToken cancellationToken) { - if (_disposed) - throw new ObjectDisposedException(nameof(GoogleDriveWriteStream)); - + ObjectDisposedException.ThrowIf(_disposed, this); if (_memoryStreamBuffer.Length == 0) return; @@ -104,18 +96,14 @@ public override async Task FlushAsync(CancellationToken cancellationToken) /// public override long Seek(long offset, SeekOrigin origin) { - if (_disposed) - throw new ObjectDisposedException(nameof(GoogleDriveWriteStream)); - + ObjectDisposedException.ThrowIf(_disposed, this); return _memoryStreamBuffer.Seek(offset, origin); } /// public override void SetLength(long value) { - if (_disposed) - throw new ObjectDisposedException(nameof(GoogleDriveWriteStream)); - + ObjectDisposedException.ThrowIf(_disposed, this); _memoryStreamBuffer.SetLength(value); } diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Constants.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Constants.cs new file mode 100644 index 000000000..58ca722df --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Constants.cs @@ -0,0 +1,22 @@ +namespace SecureFolderFS.Sdk.PhoneLink +{ + public static class Constants + { + public static readonly byte[] DISCOVERY_MAGIC = "SFFS-PHONELINK"u8.ToArray(); + public const string SECRETS_KEY_PREFIX = "PhoneLink_Secret_"; + public const string DATA_SOURCE_PHONE_LINK = $"{nameof(SecureFolderFS)}.{nameof(PhoneLink)}"; + public const byte PROTOCOL_VERSION = 1; + public const int CHALLENGE_VALIDITY_SECONDS = 30; + public const int COMMUNICATION_PORT = 41235; + public const int DISCOVERY_PORT = 41234; + public const int DISCOVERY_TIMEOUT_MS = 2000; + public const int CONNECTION_TIMEOUT_MS = 5000; + + public static class KeyTraits + { + public const int MESSAGE_BYTE_LENGTH = 1; + public const int NONCE_SIZE = 12; + public const int TAG_SIZE = 16; + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Enums/DeviceLinkMessageType.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Enums/DeviceLinkMessageType.cs new file mode 100644 index 000000000..112a3e38e --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Enums/DeviceLinkMessageType.cs @@ -0,0 +1,130 @@ +namespace SecureFolderFS.Sdk.PhoneLink.Enums +{ + /// + /// Message types for the Phone Link protocol. + /// + public enum MessageType : byte + { + /// + /// Discovery broadcast from desktop. + /// + DiscoveryRequest = 0x01, + + /// + /// Discovery response from mobile. + /// + DiscoveryResponse = 0x02, + + /// + /// Connection request from desktop. + /// + ConnectionRequest = 0x10, + + /// + /// Connection accepted by mobile. + /// + ConnectionAccepted = 0x11, + + /// + /// Connection rejected by mobile. + /// + ConnectionRejected = 0x12, + + /// + /// Challenge sent from desktop to mobile. + /// + ChallengeRequest = 0x20, + + /// + /// Signed challenge response from mobile. + /// + ChallengeResponse = 0x21, + + /// + /// Request list of available credentials. + /// + ListCredentialsRequest = 0x30, + + /// + /// List of credentials response. + /// + ListCredentialsResponse = 0x31, + + /// + /// Select a specific credential to use. + /// + SelectCredential = 0x32, + + /// + /// Credential selected confirmation. + /// + CredentialSelected = 0x33, + + // ============ Secure Pairing Protocol (0x40-0x4F) ============ + + /// + /// Initiate secure pairing from desktop. + /// Contains: Desktop ECDH public key + /// + PairingRequest = 0x40, + + /// + /// Pairing response from mobile. + /// Contains: Mobile ECDH public key + /// + PairingResponse = 0x41, + + /// + /// User confirmed verification code matches. + /// Contains: VaultCID, VaultName + /// + PairingConfirm = 0x42, + + /// + /// Pairing completed, credential created. + /// Contains: Signing public key + /// + PairingComplete = 0x43, + + /// + /// Pairing was rejected or verification failed. + /// + PairingRejected = 0x44, + + // ============ Secure Authentication Protocol (0x50-0x5F) ============ + + /// + /// Secure authentication request (encrypted payload). + /// Contains: CID + Challenge + Timestamp + Nonce + /// + SecureAuthRequest = 0x50, + + /// + /// Secure authentication response (encrypted payload). + /// Contains: Signature + /// + SecureAuthResponse = 0x51, + + /// + /// Secure session establishment using pairing credentials. + /// Contains: PairingId + Session nonce + /// + SecureSessionRequest = 0x52, + + /// + /// Secure session established. + /// Contains: Session nonce response + /// + SecureSessionAccepted = 0x53, + + /// + /// Authentication rejected (wrong CID, expired, etc.) + /// + AuthenticationRejected = 0x54, + + /// + /// Error message. + /// + Error = 0xFF + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/AuthenticationRequestModel.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/AuthenticationRequestModel.cs new file mode 100644 index 000000000..7a841a11f --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/AuthenticationRequestModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SecureFolderFS.Sdk.PhoneLink.Models +{ + [Bindable(true)] + public sealed partial class AuthenticationRequestModel : ObservableObject + { + [ObservableProperty] private string _VaultName; + [ObservableProperty] private string _DesktopName; + [ObservableProperty] private string _CredentialName; + + public AuthenticationRequestModel(string vaultName, string desktopName, string credentialName) + { + VaultName = vaultName; + DesktopName = desktopName; + CredentialName = credentialName; + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/ConnectedDevice.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/ConnectedDevice.cs new file mode 100644 index 000000000..7cd67a6e1 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/ConnectedDevice.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.PhoneLink.Enums; +using SecureFolderFS.Shared.Helpers; + +namespace SecureFolderFS.Sdk.PhoneLink.Models +{ + public sealed class ConnectedDevice : IDisposable + { + private readonly TcpClient _tcpClient; + private bool _disposed; + + public Stream DeviceStream { get; } + + /// + /// Gets a value indicating whether the device is connected. + /// + public bool IsConnected => !_disposed && _tcpClient.Connected; + + private ConnectedDevice(TcpClient tcpClient, Stream deviceStream) + { + _tcpClient = tcpClient; + DeviceStream = deviceStream; + } + + public async Task SendMessageAsync(byte[] message, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var lengthBytes = BitConverter.GetBytes(message.Length); + await DeviceStream.WriteAsync(lengthBytes, cancellationToken); + await DeviceStream.WriteAsync(message, cancellationToken); + await DeviceStream.FlushAsync(cancellationToken); + } + + public async Task SendMessageAsync(byte[] payload, MessageType type, CancellationToken cancellationToken) + { + var message = new byte[1 + payload.Length]; + message[0] = (byte)type; + payload.CopyTo(message, 1); + await SendMessageAsync(message, cancellationToken); + } + + public async Task ReceiveMessageAsync(CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var lengthBytes = new byte[4]; + var bytesRead = await DeviceStream.ReadAsync(lengthBytes, cancellationToken); + if (bytesRead == 0) + throw new IOException("Connection closed by remote host."); + if (bytesRead < 4) + throw new IOException("Connection closed unexpectedly."); + + var length = BitConverter.ToInt32(lengthBytes); + if (length <= 0 || length > 1024 * 1024) // Max 1MB message + throw new IOException($"Invalid message length: {length}"); + + var message = new byte[length]; + var totalRead = 0; + + while (totalRead < length) + { + bytesRead = await DeviceStream.ReadAsync(message.AsMemory(totalRead, length - totalRead), cancellationToken); + if (bytesRead == 0) + throw new IOException("Connection closed unexpectedly."); + + totalRead += bytesRead; + } + + return message; + } + + public static async Task ConnectAsync(DiscoveredDevice discoveredDevice, CancellationToken cancellationToken) + { + var tcpClient = new TcpClient(); + try + { + // Configure for low latency before connecting + tcpClient.NoDelay = true; + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(Constants.CONNECTION_TIMEOUT_MS); + + await tcpClient.ConnectAsync(discoveredDevice.IpAddress, discoveredDevice.Port, timeoutCts.Token); + var stream = tcpClient.GetStream(); + + return new ConnectedDevice(tcpClient, stream); + } + catch + { + tcpClient.Dispose(); + throw; + } + } + + /// + /// Creates a from an already accepted . + /// + /// The accepted TCP client. + /// A new instance. + public static ConnectedDevice FromTcpClient(TcpClient tcpClient) + { + return new ConnectedDevice(tcpClient, tcpClient.GetStream()); + } + + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + SafetyHelpers.NoFailure(() => DeviceStream.Dispose()); + SafetyHelpers.NoFailure(() => _tcpClient.Dispose()); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/CredentialsStoreModel.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/CredentialsStoreModel.cs new file mode 100644 index 000000000..3ab14b0cd --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/CredentialsStoreModel.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.PhoneLink.ViewModels; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; +using static SecureFolderFS.Sdk.PhoneLink.Constants; + +namespace SecureFolderFS.Sdk.PhoneLink.Models +{ + public sealed class CredentialsStoreModel : IPersistable, IDisposable + { + private readonly IPropertyStore _propertyStore; + private readonly IAsyncSerializer _streamSerializer; + private readonly List _credentials = []; + private bool _disposed; + + /// + /// All stored credentials. + /// + public IReadOnlyList Credentials => _credentials; + + public CredentialsStoreModel(IPropertyStore propertyStore, IAsyncSerializer streamSerializer) + { + _propertyStore = propertyStore; + _streamSerializer = streamSerializer; + } + + /// + public async Task InitAsync(CancellationToken cancellationToken = default) + { + var rawCredentials = await _propertyStore.GetValueAsync(DATA_SOURCE_PHONE_LINK, null, cancellationToken); + if (string.IsNullOrEmpty(rawCredentials)) + return; + + var deserialized = await _streamSerializer.TryDeserializeFromStringAsync>(rawCredentials, cancellationToken); + if (deserialized is null) + return; + + _credentials.Clear(); + _credentials.AddRange(deserialized); + } + + /// + public async Task SaveAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var serialized = await _streamSerializer.TrySerializeToStringAsync(_credentials, cancellationToken); + if (serialized is null) + return; + + await _propertyStore.SetValueAsync(DATA_SOURCE_PHONE_LINK, serialized, cancellationToken); + } + + /// + /// Enrolls a credential with pairing data from desktop. + /// Generates and encrypts the signing key. + /// + public async Task EnrollCredentialAsync( + CredentialViewModel credential, + string credentialId, + string vaultName, + string desktopName, + string pairingId, + byte[] challenge, + byte[] sessionSecret) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + credential.CredentialId = credentialId; + credential.VaultName = vaultName; + credential.MachineName = desktopName; + credential.PairingId = pairingId; + credential.Challenge = challenge; + + // Derive encryption key from session secret + var encryptionKey = DeriveEncryptionKey(sessionSecret, credentialId); + + try + { + // Generate and encrypt the HMAC key + credential.GenerateAndEncryptHmacKey(encryptionKey); + + // Add to credentials list if not already there + if (!_credentials.Contains(credential)) + { + _credentials.Add(credential); + } + + // Store the encryption key securely (not the session secret) + await StoreEncryptionKeyAsync(pairingId, encryptionKey); + + // Save updated credentials + await SaveAsync(); + } + finally + { + CryptographicOperations.ZeroMemory(encryptionKey); + } + } + + /// + /// Derives an encryption key from the session secret. + /// + private static byte[] DeriveEncryptionKey(byte[] sessionSecret, string credentialId) + { + return HKDF.DeriveKey( + HashAlgorithmName.SHA256, + sessionSecret, + 32, + Encoding.UTF8.GetBytes(credentialId), + "PhoneLink-SigningKeyEncryption-v2"u8.ToArray()); + } + + /// + /// Stores an encryption key securely. + /// + private async Task StoreEncryptionKeyAsync(string pairingId, byte[] encryptionKey) + { + var encoded = Convert.ToBase64String(encryptionKey); + await _propertyStore.SetValueAsync(SECRETS_KEY_PREFIX + pairingId, encoded); + } + + /// + /// Gets a credential by its CID. + /// + public CredentialViewModel? GetByCredentialId(string credentialId) + { + return _credentials.FirstOrDefault(c => c.CredentialId == credentialId); + } + + /// + /// Gets a credential by pairing ID. + /// + public CredentialViewModel? GetByPairingId(string pairingId) + { + return _credentials.FirstOrDefault(c => c.PairingId == pairingId); + } + + /// + /// Gets the encryption key for decrypting the signing key. + /// + public async Task GetEncryptionKeyAsync(string pairingId) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + try + { + var encoded = await _propertyStore.GetValueAsync(SECRETS_KEY_PREFIX + pairingId); + if (string.IsNullOrEmpty(encoded)) + return null; + + return Convert.FromBase64String(encoded); + } + catch + { + return null; + } + } + + /// + /// Deletes a credential. + /// + public async Task DeleteCredentialAsync(string id) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var credential = _credentials.FirstOrDefault(c => c.Id == id); + if (credential == null) + return; + + // Remove encryption key + if (!string.IsNullOrEmpty(credential.PairingId)) + await _propertyStore.RemoveAsync(SECRETS_KEY_PREFIX + credential.PairingId); + + credential.Dispose(); + _credentials.Remove(credential); + await SaveAsync(); + } + + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _credentials.DisposeAll(); + _credentials.Clear(); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/DeviceConnectionListener.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/DeviceConnectionListener.cs new file mode 100644 index 000000000..0390e26d1 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/DeviceConnectionListener.cs @@ -0,0 +1,194 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Shared.Helpers; +using static SecureFolderFS.Sdk.PhoneLink.Constants; + +namespace SecureFolderFS.Sdk.PhoneLink.Models +{ + /// + /// Handles both UDP discovery responses and TCP connection acceptance for the mobile device. + /// This is the mobile-side network listener counterpart to . + /// + public sealed class DeviceConnectionListener : IDisposable + { + private readonly string _deviceId; + private readonly string _deviceName; + private readonly object _lock = new(); + private UdpClient? _udpClient; + private TcpListener? _tcpListener; + private ConnectedDevice? _currentConnection; + private CancellationTokenSource? _listenerCts; + private bool _disposed; + + /// + /// Event raised when a new connection is accepted. + /// + public event EventHandler? ConnectionAccepted; + + /// + /// Gets a value indicating whether the listener is currently active. + /// + public bool IsListening { get; private set; } + + public DeviceConnectionListener(string deviceId, string deviceName) + { + _deviceId = deviceId; + _deviceName = deviceName; + } + + /// + /// Starts listening for UDP discovery requests and TCP connections. + /// + public Task StartListeningAsync(CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (IsListening || _disposed) + return Task.CompletedTask; + + _listenerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // Start UDP discovery responder with socket reuse + _udpClient = new UdpClient(); + _udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + _udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, DISCOVERY_PORT)); + _ = Task.Run(() => ListenForDiscoveryAsync(_listenerCts.Token)); + + // Start TCP listener with socket reuse and NoDelay for faster connections + _tcpListener = new TcpListener(IPAddress.Any, COMMUNICATION_PORT); + _tcpListener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + _tcpListener.Start(); + _ = Task.Run(() => AcceptConnectionsAsync(_listenerCts.Token)); + + IsListening = true; + } + + return Task.CompletedTask; + } + + /// + /// Stops listening for discovery requests and TCP connections. + /// + public void StopListening() + { + lock (_lock) + { + if (!IsListening) + return; + + IsListening = false; + + SafetyHelpers.NoFailure(() => _listenerCts?.Cancel()); + SafetyHelpers.NoFailure(() => _udpClient?.Close()); + SafetyHelpers.NoFailure(() => _tcpListener?.Stop()); + + CloseCurrentConnection(); + } + } + + /// + /// Closes the current connection if any. + /// + public void CloseCurrentConnection() + { + var connection = Interlocked.Exchange(ref _currentConnection, null); + SafetyHelpers.NoFailure(() => connection?.Dispose()); + } + + private async Task ListenForDiscoveryAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested && !_disposed) + { + try + { + var result = await _udpClient!.ReceiveAsync(cancellationToken); + if (!ProtocolSerializer.ValidateDiscoveryRequest(result.Buffer)) + continue; + + var response = ProtocolSerializer.CreateDiscoveryResponse(_deviceId, _deviceName, COMMUNICATION_PORT); + await _udpClient.SendAsync(response, response.Length, result.RemoteEndPoint); + } + catch (OperationCanceledException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + catch (SocketException) + { + // Socket error - wait a bit and continue if not cancelled + if (!cancellationToken.IsCancellationRequested) + await Task.Delay(100, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + catch (Exception) + { + // Log or handle other discovery errors if needed + if (!cancellationToken.IsCancellationRequested) + await Task.Delay(100, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + } + + private async Task AcceptConnectionsAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested && !_disposed) + { + try + { + var tcpClient = await _tcpListener!.AcceptTcpClientAsync(cancellationToken); + + // Configure for low latency + tcpClient.NoDelay = true; + + var connectedDevice = ConnectedDevice.FromTcpClient(tcpClient); + + // Store and close previous connection if any + var previousConnection = Interlocked.Exchange(ref _currentConnection, connectedDevice); + SafetyHelpers.NoFailure(() => previousConnection?.Dispose()); + + // Notify subscribers about the new connection + ConnectionAccepted?.Invoke(this, connectedDevice); + } + catch (OperationCanceledException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + catch (SocketException) + { + // Socket error - wait a bit and continue if not cancelled + if (!cancellationToken.IsCancellationRequested) + await Task.Delay(100, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + catch (Exception) + { + // Log or handle other connection errors if needed + if (!cancellationToken.IsCancellationRequested) + await Task.Delay(100, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + StopListening(); + + SafetyHelpers.NoFailure(() => _listenerCts?.Dispose()); + SafetyHelpers.NoFailure(() => _udpClient?.Dispose()); + + ConnectionAccepted = null; + } + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/DeviceDiscovery.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/DeviceDiscovery.cs new file mode 100644 index 000000000..e797ccdfe --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/DeviceDiscovery.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Shared.Helpers; + +namespace SecureFolderFS.Sdk.PhoneLink.Models +{ + /// + /// Discovers mobile devices on the local network via UDP broadcast. + /// + public sealed class DeviceDiscovery : IDisposable + { + private readonly string _desktopName; + private CancellationTokenSource? _discoveryCts; + private bool _disposed; + + /// + /// Event raised when a new device is discovered. + /// + public event EventHandler? DeviceDiscovered; + + public DeviceDiscovery(string desktopName = "SecureFolderFS Desktop") + { + _desktopName = desktopName; + } + + /// + /// Starts discovering devices on the local network. + /// + /// Discovery timeout in milliseconds. + /// A to cancel the operation. + /// A list of discovered devices. + public async Task> DiscoverDevicesAsync( + int timeoutMs = Constants.DISCOVERY_TIMEOUT_MS, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var devices = new List(); + _discoveryCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + UdpClient? udpClient = null; + try + { + // Create a new UdpClient for each discovery operation + udpClient = new UdpClient(); + udpClient.EnableBroadcast = true; + udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + + // Set receive timeout for faster response + udpClient.Client.ReceiveTimeout = timeoutMs; + + // Bind to receive responses + udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, 0)); + + // Send broadcast discovery request + var discoveryPacket = ProtocolSerializer.CreateDiscoveryRequest(_desktopName); + var broadcastEndpoint = new IPEndPoint(IPAddress.Broadcast, Constants.DISCOVERY_PORT); + + // Send multiple times for reliability (UDP can be lost) + await udpClient.SendAsync(discoveryPacket, discoveryPacket.Length, broadcastEndpoint); + + // Small delay then send again for reliability + await Task.Delay(50, cancellationToken); + await udpClient.SendAsync(discoveryPacket, discoveryPacket.Length, broadcastEndpoint); + + // Listen for responses with timeout + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_discoveryCts.Token); + timeoutCts.CancelAfter(timeoutMs); + + while (!timeoutCts.Token.IsCancellationRequested) + { + try + { + var result = await udpClient.ReceiveAsync(timeoutCts.Token); + var device = ProtocolSerializer.ParseDiscoveryResponse( + result.Buffer, + result.RemoteEndPoint.Address.ToString()); + + if (device != null && !devices.Exists(d => d.DeviceId == device.DeviceId)) + { + devices.Add(device); + DeviceDiscovered?.Invoke(this, device); + + // If we found a device, we can return early for faster response + break; + } + } + catch (OperationCanceledException) + { + break; + } + catch (SocketException) + { + // Socket error during receive - continue if not cancelled + if (timeoutCts.Token.IsCancellationRequested) + break; + } + } + } + catch (OperationCanceledException) + { + // Discovery was cancelled or timed out - this is normal + } + catch (SocketException) + { + // Socket error - return whatever devices we found + } + finally + { + SafetyHelpers.NoFailure(() => udpClient?.Close()); + SafetyHelpers.NoFailure(() => udpClient?.Dispose()); + } + + return devices; + } + + /// + /// Stops the current discovery operation. + /// + public void StopDiscovery() + { + SafetyHelpers.NoFailure(() => _discoveryCts?.Cancel()); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + StopDiscovery(); + SafetyHelpers.NoFailure(() => _discoveryCts?.Dispose()); + + DeviceDiscovered = null; + } + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/DiscoveredDevice.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/DiscoveredDevice.cs new file mode 100644 index 000000000..8270f7a9d --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/DiscoveredDevice.cs @@ -0,0 +1,41 @@ +using System; + +namespace SecureFolderFS.Sdk.PhoneLink.Models; + +/// +/// Represents a discovered mobile device. +/// +public class DiscoveredDevice +{ + /// + /// Unique identifier for this device. + /// + public required string DeviceId { get; init; } + + /// + /// User-friendly name of the device. + /// + public required string DeviceName { get; init; } + + /// + /// IP address of the device. + /// + public required string IpAddress { get; init; } + + /// + /// Port for TCP communication. + /// + public required int Port { get; init; } + + /// + /// Public key of the device (for verification). + /// + public byte[]? PublicKey { get; init; } + + /// + /// Time when the device was discovered. + /// + public DateTime DiscoveredAt { get; init; } = DateTime.UtcNow; + + public override string ToString() => $"{DeviceName} ({IpAddress}:{Port})"; +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/ProtocolSerializer.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/ProtocolSerializer.cs new file mode 100644 index 000000000..43bd84748 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/ProtocolSerializer.cs @@ -0,0 +1,275 @@ +using System; +using System.IO; +using SecureFolderFS.Sdk.PhoneLink.Enums; +using static SecureFolderFS.Sdk.PhoneLink.Constants; + +namespace SecureFolderFS.Sdk.PhoneLink.Models +{ + public static class ProtocolSerializer + { + #region Discovery + + /// + /// Creates a discovery request packet. + /// + public static byte[] CreateDiscoveryRequest(string desktopName) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write(DISCOVERY_MAGIC); + writer.Write(PROTOCOL_VERSION); + writer.Write((byte)MessageType.DiscoveryRequest); + writer.Write(desktopName); + + return ms.ToArray(); + } + + /// + /// Validates a discovery request packet. + /// + public static bool ValidateDiscoveryRequest(byte[] buffer) + { + if (buffer.Length < DISCOVERY_MAGIC.Length + 2) + return false; + + var magic = buffer.AsSpan(0, DISCOVERY_MAGIC.Length).ToArray(); + return magic.SequenceEqual(DISCOVERY_MAGIC); + } + + /// + /// Creates a discovery response packet. + /// + public static byte[] CreateDiscoveryResponse(string deviceId, string deviceName, int communicationPort) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write(DISCOVERY_MAGIC); + writer.Write(PROTOCOL_VERSION); + writer.Write((byte)MessageType.DiscoveryResponse); + writer.Write(deviceId); + writer.Write(deviceName); + writer.Write(communicationPort); + + // Include empty public key placeholder (actual signing key comes after pairing) + writer.Write(0); + + return ms.ToArray(); + } + + /// + /// Parses a discovery response packet. + /// + public static DiscoveredDevice? ParseDiscoveryResponse(byte[] data, string senderIp) + { + try + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + + // Verify magic bytes + var magic = reader.ReadBytes(DISCOVERY_MAGIC.Length); + if (!magic.SequenceEqual(DISCOVERY_MAGIC)) + return null; + + var version = reader.ReadByte(); + if (version != PROTOCOL_VERSION) + return null; + + var messageType = (MessageType)reader.ReadByte(); + if (messageType != MessageType.DiscoveryResponse) + return null; + + var deviceId = reader.ReadString(); + var deviceName = reader.ReadString(); + var port = reader.ReadInt32(); + var publicKeyLength = reader.ReadInt32(); + var publicKey = publicKeyLength > 0 ? reader.ReadBytes(publicKeyLength) : null; + + return new DiscoveredDevice + { + DeviceId = deviceId, + DeviceName = deviceName, + IpAddress = senderIp, + Port = port, + PublicKey = publicKey + }; + } + catch + { + return null; + } + } + + #endregion + + #region Pairing + + public static byte[] CreatePairingRequest(string machineName, byte[] ecdhPublicKey) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write((byte)MessageType.PairingRequest); + writer.Write(machineName); + writer.Write(ecdhPublicKey.Length); + writer.Write(ecdhPublicKey); + + return ms.ToArray(); + } + + public static byte[] CreatePairingConfirmMessage(string credentialId, string vaultName, string pairingId, byte[] challenge) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write((byte)MessageType.PairingConfirm); + writer.Write(credentialId); + writer.Write(vaultName); + writer.Write(pairingId); + writer.Write(challenge.Length); + writer.Write(challenge); + + return ms.ToArray(); + } + + public static byte[] ParsePairingResponse(byte[] data) + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + + reader.ReadByte(); // Skip message type + var keyLength = reader.ReadInt32(); + return reader.ReadBytes(keyLength); + } + + public static byte[] CreatePairingResponse(byte[] ecdhPublicKey) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write((byte)MessageType.PairingResponse); + writer.Write(ecdhPublicKey.Length); + writer.Write(ecdhPublicKey); + + return ms.ToArray(); + } + + public static void ParsePairingConfirm(byte[] message, out string credentialId, out string vaultName, out string pairingId, out byte[] challenge) + { + using var ms = new MemoryStream(message); + using var reader = new BinaryReader(ms); + + reader.ReadByte(); // Skip message type + credentialId = reader.ReadString(); + vaultName = reader.ReadString(); + pairingId = reader.ReadString(); + var challengeLength = reader.ReadInt32(); + challenge = reader.ReadBytes(challengeLength); + } + + public static byte[] ParsePairingComplete(byte[] data) + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + + reader.ReadByte(); // Skip message type + var hmacLength = reader.ReadInt32(); + return reader.ReadBytes(hmacLength); + } + + public static byte[] CreatePairingComplete(byte[] hmacResult) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write((byte)MessageType.PairingComplete); + writer.Write(hmacResult.Length); + writer.Write(hmacResult); + + return ms.ToArray(); + } + + #endregion + + #region Secure Session + + public static byte[] CreateSecureSessionRequest(string pairingId, byte[] nonce, byte[] ecdhPublicKey) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write((byte)MessageType.SecureSessionRequest); + writer.Write(pairingId); + writer.Write(nonce.Length); + writer.Write(nonce); + writer.Write(ecdhPublicKey.Length); + writer.Write(ecdhPublicKey); + + return ms.ToArray(); + } + + public static void ParseSecureSessionAccepted(byte[] data, out byte[] nonce, out byte[] ecdhPublicKey) + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + + reader.ReadByte(); // Skip message type + + var nonceLength = reader.ReadInt32(); + nonce = reader.ReadBytes(nonceLength); + + var keyLength = reader.ReadInt32(); + ecdhPublicKey = reader.ReadBytes(keyLength); + } + + public static byte[] CreateSecureSessionAccepted(byte[] nonce, byte[] ecdhPublicKey) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write((byte)MessageType.SecureSessionAccepted); + writer.Write(nonce.Length); + writer.Write(nonce); + writer.Write(ecdhPublicKey.Length); + writer.Write(ecdhPublicKey); + + return ms.ToArray(); + } + + #endregion + + #region Authentication + + /// + /// Creates auth request for challenge-sign model. + /// Sends the persistent challenge for mobile to sign. + /// + public static byte[] CreateSecureAuthRequest(string credentialId, byte[] persistentChallenge, long timestamp) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write(credentialId); + writer.Write(persistentChallenge.Length); + writer.Write(persistentChallenge); + writer.Write(timestamp); + + return ms.ToArray(); + } + + public static byte[] CreateAuthenticationRejected(string reason) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write((byte)MessageType.AuthenticationRejected); + writer.Write(reason); + + return ms.ToArray(); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/SecureChannelModel.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/SecureChannelModel.cs new file mode 100644 index 000000000..e8111ed2c --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Models/SecureChannelModel.cs @@ -0,0 +1,134 @@ +using System; +using System.Security.Cryptography; +using System.Threading; +using static SecureFolderFS.Sdk.PhoneLink.Constants; + +namespace SecureFolderFS.Sdk.PhoneLink.Models +{ + /// + /// Provides an AES-256-GCM encrypted communication channel. + /// Used after secure pairing is established. + /// + public sealed class SecureChannelModel : IDisposable + { + private readonly byte[] _encryptionKey; + private long _sendSequence; + private long _receiveSequence; + private bool _disposed; + + /// + /// Creates a secure channel from a shared secret. + /// + public SecureChannelModel(byte[] sharedSecret, byte[]? salt = null) + { + salt ??= []; + + _encryptionKey = HKDF.DeriveKey( + HashAlgorithmName.SHA256, + sharedSecret, + 32, + salt, + "PhoneLink-Encryption-v1"u8.ToArray()); + } + + /// + /// Encrypts a message using AES-256-GCM. + /// + public byte[] Encrypt(byte[] plaintext) + { + ThrowIfDisposed(); + + using var aes = new AesGcm(_encryptionKey, KeyTraits.TAG_SIZE); + + var nonce = new byte[KeyTraits.NONCE_SIZE]; + var seqBytes = BitConverter.GetBytes(Interlocked.Increment(ref _sendSequence)); + Array.Copy(seqBytes, nonce, Math.Min(seqBytes.Length, 4)); + RandomNumberGenerator.Fill(nonce.AsSpan(4)); + + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[KeyTraits.TAG_SIZE]; + + aes.Encrypt(nonce, plaintext, ciphertext, tag); + + var output = new byte[KeyTraits.NONCE_SIZE + ciphertext.Length + KeyTraits.TAG_SIZE]; + nonce.CopyTo(output, 0); + ciphertext.CopyTo(output, KeyTraits.NONCE_SIZE); + tag.CopyTo(output, KeyTraits.NONCE_SIZE + ciphertext.Length); + + return output; + } + + /// + /// Decrypts a message. + /// + public byte[] Decrypt(byte[] encryptedData) + { + ThrowIfDisposed(); + + if (encryptedData.Length < KeyTraits.NONCE_SIZE + KeyTraits.TAG_SIZE) + throw new CryptographicException("Invalid encrypted data length"); + + using var aes = new AesGcm(_encryptionKey, KeyTraits.TAG_SIZE); + + var nonce = encryptedData.AsSpan(0, KeyTraits.NONCE_SIZE); + var ciphertextLength = encryptedData.Length - KeyTraits.NONCE_SIZE - KeyTraits.TAG_SIZE; + var ciphertext = encryptedData.AsSpan(KeyTraits.NONCE_SIZE, ciphertextLength); + var tag = encryptedData.AsSpan(KeyTraits.NONCE_SIZE + ciphertextLength, KeyTraits.TAG_SIZE); + + var plaintext = new byte[ciphertextLength]; + aes.Decrypt(nonce, ciphertext, tag, plaintext); + + Interlocked.Increment(ref _receiveSequence); + + return plaintext; + } + + /// + /// Computes 6-digit verification code from shared secret. + /// + public static string ComputeVerificationCode(byte[] sharedSecret) + { + var hash = HKDF.DeriveKey( + HashAlgorithmName.SHA256, + sharedSecret, + 4, + [], + "PhoneLink-VerificationCode-v1"u8.ToArray()); + + var code = BitConverter.ToUInt32(hash) % 1000000; + return code.ToString("D6"); + } + + /// + /// Performs ECDH key exchange and derives shared secret. + /// + public static byte[] DeriveSharedSecret(ECDiffieHellman localPrivateKey, byte[] remotePublicKey) + { + using var remoteKey = ECDiffieHellman.Create(); + remoteKey.ImportSubjectPublicKeyInfo(remotePublicKey, out _); + return localPrivateKey.DeriveKeyMaterial(remoteKey.PublicKey); + } + + /// + /// Generates a new ECDH key pair. + /// + public static ECDiffieHellman GenerateKeyPair() + { + return ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + CryptographicOperations.ZeroMemory(_encryptionKey); + } + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Results/DeviceLinkAuthenticationResult.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Results/DeviceLinkAuthenticationResult.cs new file mode 100644 index 000000000..08b2e58bb --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Results/DeviceLinkAuthenticationResult.cs @@ -0,0 +1,29 @@ +using System; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Sdk.PhoneLink.Results +{ + public sealed class DeviceLinkAuthenticationResult : Result + { + /// + /// The credential ID that was used for authentication. + /// + public required string CredentialId { get; init; } + + public DeviceLinkAuthenticationResult(IKeyBytes value) + : base(value) + { + } + + private DeviceLinkAuthenticationResult(Exception? exception) + : base(exception) + { + } + + private DeviceLinkAuthenticationResult(IKeyBytes value, Exception exception) + : base(value, exception) + { + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Results/DeviceLinkPairingResult.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Results/DeviceLinkPairingResult.cs new file mode 100644 index 000000000..048695c4a --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Results/DeviceLinkPairingResult.cs @@ -0,0 +1,44 @@ +using System; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Sdk.PhoneLink.Results +{ + public sealed class DeviceLinkPairingResult : Result + { + /// + /// The credential ID binding this pairing to a specific vault. + /// + public required string CredentialId { get; init; } + + /// + /// The pairing ID for salt derivation. + /// + public required string PairingId { get; init; } + + /// + /// The mobile device's unique identifier. + /// + public required string MobileDeviceId { get; init; } + + /// + /// Human-readable mobile device name. + /// + public required string MobileDeviceName { get; init; } + + public DeviceLinkPairingResult(IKeyBytes value) + : base(value) + { + } + + private DeviceLinkPairingResult(Exception? exception) + : base(exception) + { + } + + private DeviceLinkPairingResult(IKeyBytes value, Exception exception) + : base(value, exception) + { + } + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/SecureFolderFS.Sdk.PhoneLink.csproj b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/SecureFolderFS.Sdk.PhoneLink.csproj new file mode 100644 index 000000000..4c84fc1d8 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/SecureFolderFS.Sdk.PhoneLink.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + latest + enable + + + + + + + + + + + + + + + diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Services/DeviceLinkService.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Services/DeviceLinkService.cs new file mode 100644 index 000000000..9eba85d3e --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Services/DeviceLinkService.cs @@ -0,0 +1,535 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.PhoneLink.Enums; +using SecureFolderFS.Sdk.PhoneLink.Models; +using SecureFolderFS.Sdk.PhoneLink.ViewModels; +using SecureFolderFS.Shared.Helpers; +using static SecureFolderFS.Sdk.PhoneLink.Constants; + +namespace SecureFolderFS.Sdk.PhoneLink.Services +{ + /// + public sealed class DeviceLinkService : IDeviceLinkService + { + private readonly CredentialsStoreModel _credentialStoreModel; + private readonly DeviceConnectionListener _connectionListener; + private readonly object _lock = new(); + private CancellationTokenSource? _serviceCts; + private TaskCompletionSource? _pairingConfirmationTcs; + private TaskCompletionSource? _authConfirmationTcs; + private CredentialViewModel? _currentCredential; + private SecureChannelModel? _secureChannel; + private bool _disposed; + + public DeviceLinkService(string deviceName, string deviceId, CredentialsStoreModel credentialStoreModel) + { + _credentialStoreModel = credentialStoreModel; + _connectionListener = new DeviceConnectionListener(deviceId, deviceName); + _connectionListener.ConnectionAccepted += OnConnectionAccepted; + } + + /// + public event EventHandler? EnrollmentCompleted; + + /// + public event EventHandler? PairingRequested; + + /// + public event EventHandler? AuthenticationRequested; + + /// + public event EventHandler? VerificationCodeReady; + + /// + public event EventHandler? Disconnected; + + /// + public event EventHandler? AuthenticationCompleted; + + /// + public bool IsListening => _connectionListener.IsListening; + + /// + public Task StartListeningAsync(CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (_disposed) + return Task.CompletedTask; + + SafetyHelpers.NoFailure(() => _serviceCts?.Dispose()); + _serviceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + } + + return _connectionListener.StartListeningAsync(_serviceCts.Token); + } + + /// + public void StopListening() + { + lock (_lock) + { + SafetyHelpers.NoFailure(() => _serviceCts?.Cancel()); + _connectionListener.StopListening(); + } + } + + /// + public void ConfirmPairingRequest(bool value) + { + _pairingConfirmationTcs?.TrySetResult(value); + } + + /// + /// Call this from UI to confirm or reject an authentication request. + /// + public void ConfirmAuthentication(bool confirmed) + { + _authConfirmationTcs?.TrySetResult(confirmed); + } + + private void OnConnectionAccepted(object? sender, ConnectedDevice device) + { + CancellationToken token; + lock (_lock) + { + if (_disposed || _serviceCts == null) + { + device.Dispose(); + return; + } + token = _serviceCts.Token; + } + + _ = HandleConnectionAsync(device, token); + } + + private async Task HandleConnectionAsync(ConnectedDevice device, CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested && device.IsConnected) + { + byte[] message; + try + { + message = await device.ReceiveMessageAsync(cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + catch (IOException) + { + // Connection closed normally + break; + } + + var messageType = (MessageType)message[0]; + + try + { + switch (messageType) + { + case MessageType.PairingRequest: + await HandlePairingRequestAsync(device, message, cancellationToken); + CleanupSessionState(); // Clean up after pairing completes + break; + + case MessageType.SecureSessionRequest: + await HandleSecureSessionRequestAsync(device, message, cancellationToken); + // Don't clean up - session state needed for SecureAuthRequest + break; + + case MessageType.SecureAuthRequest: + await HandleSecureAuthRequestAsync(device, message, cancellationToken); + CleanupSessionState(); // Clean up after authentication completes + break; + + default: + Debug.WriteLine($"Unknown message type: {messageType}"); + break; + } + } + catch (OperationCanceledException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + catch (IOException) + { + // Connection closed during operation + break; + } + catch (Exception ex) + { + Debug.WriteLine($"Error handling message {messageType}: {ex.Message}"); + // Continue processing next message unless connection is broken + if (!device.IsConnected) + break; + } + } + } + finally + { + device.Dispose(); + CleanupSessionState(); + SafetyHelpers.NoFailure(() => Disconnected?.Invoke(this, EventArgs.Empty)); + } + } + + #region Secure Pairing + + private async Task HandlePairingRequestAsync(ConnectedDevice device, byte[] message, + CancellationToken cancellationToken) + { + // Clean up any stale session state from previous operations + CleanupSessionState(); + + // Parse desktop's ECDH public key + using var ms = new MemoryStream(message); + using var reader = new BinaryReader(ms); + + reader.ReadByte(); // Skip message type + var desktopName = reader.ReadString(); // Read desktop name + var keyLength = reader.ReadInt32(); + var desktopEcdhPublicKey = reader.ReadBytes(keyLength); + + // Generate our ECDH keypair + using var ecdhKeyPair = SecureChannelModel.GenerateKeyPair(); + var myPublicKey = ecdhKeyPair.ExportSubjectPublicKeyInfo(); + + // Derive shared secret BEFORE sending response (so we can show code immediately) + var sharedSecret = SecureChannelModel.DeriveSharedSecret(ecdhKeyPair, desktopEcdhPublicKey); + + try + { + // Compute verification code + var verificationCode = SecureChannelModel.ComputeVerificationCode(sharedSecret); + + // Send our public key response + var response = ProtocolSerializer.CreatePairingResponse(myPublicKey); + await device.SendMessageAsync(response, cancellationToken); + + // Notify UI to display verification code and wait for user confirmation + _pairingConfirmationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Show the pairing request with verification code + PairingRequested?.Invoke(this, new PairingRequestViewModel(desktopName, string.Empty, verificationCode)); + VerificationCodeReady?.Invoke(this, verificationCode); + + // Wait for user confirmation on mobile (user confirms code matches) + bool userConfirmed; + using (cancellationToken.Register(() => _pairingConfirmationTcs.TrySetCanceled())) + { + userConfirmed = await _pairingConfirmationTcs.Task; + } + + if (!userConfirmed) + { + // Wait for desktop's message and reject it + await SafetyHelpers.NoFailureAsync(async () => + { + using var rejectCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, rejectCts.Token); + _ = await device.ReceiveMessageAsync(linkedCts.Token); + await device.SendMessageAsync([(byte)MessageType.PairingRejected], linkedCts.Token); + }); + return; + } + + // Now wait for pairing confirmation from desktop (which includes vault info) + var confirmMessage = await device.ReceiveMessageAsync(cancellationToken); + var confirmType = (MessageType)confirmMessage[0]; + + if (confirmType == MessageType.PairingRejected) + return; + + if (confirmType != MessageType.PairingConfirm) + return; + + // Parse pairing confirmation + ProtocolSerializer.ParsePairingConfirm(confirmMessage, out var credentialId, out var vaultName, out var pairingId, out var challenge); + + // Create and enroll credential with persistent challenge + var credential = new CredentialViewModel() + { + DisplayName = vaultName, + CreatedAt = DateTime.UtcNow + }; + await _credentialStoreModel.EnrollCredentialAsync( + credential, + credentialId, + vaultName, + desktopName, + pairingId, + challenge, + sharedSecret); + + // Get the encryption key to decrypt HMAC key for computing the initial HMAC + var encryptionKey = await _credentialStoreModel.GetEncryptionKeyAsync(pairingId); + if (encryptionKey == null) + { + await device.SendMessageAsync([(byte)MessageType.PairingRejected], cancellationToken); + return; + } + + try + { + // Decrypt HMAC key so we can compute HMAC + credential.DecryptHmacKey(encryptionKey); + + // Compute HMAC over the persistent challenge data + var challengeData = BuildChallengeData(credentialId, challenge); + var hmacResult = credential.ComputeHmac(challengeData); + + // Send pairing complete with HMAC result + var completeMessage = ProtocolSerializer.CreatePairingComplete(hmacResult); + await device.SendMessageAsync(completeMessage, cancellationToken); + + // Clear the decrypted key from memory + credential.ClearDecryptedKey(); + + // Notify UI that enrollment is complete + EnrollmentCompleted?.Invoke(this, credential); + } + finally + { + CryptographicOperations.ZeroMemory(encryptionKey); + } + } + finally + { + CryptographicOperations.ZeroMemory(sharedSecret); + } + } + + #endregion + + #region Secure Authentication + + private async Task HandleSecureSessionRequestAsync(ConnectedDevice device, byte[] message, + CancellationToken cancellationToken) + { + // Clean up any stale session state from previous operations + CleanupSessionState(); + + // Parse request + using var ms = new MemoryStream(message); + using var reader = new BinaryReader(ms); + + reader.ReadByte(); // Skip message type + var pairingId = reader.ReadString(); + var nonceLength = reader.ReadInt32(); + var desktopNonce = reader.ReadBytes(nonceLength); + var keyLength = reader.ReadInt32(); + var desktopEcdhPublicKey = reader.ReadBytes(keyLength); + + // Find credential by pairing ID + var credential = _credentialStoreModel.GetByPairingId(pairingId); + if (credential == null) + { + await device.SendMessageAsync(ProtocolSerializer.CreateAuthenticationRejected("Unknown pairing"), cancellationToken); + return; + } + + // Store credential for later use in auth request + _currentCredential = credential; + + // Generate fresh ECDH keypair for this session (ephemeral) + using var ecdhKeyPair = SecureChannelModel.GenerateKeyPair(); + var myPublicKey = ecdhKeyPair.ExportSubjectPublicKeyInfo(); + + // Derive session secret using ECDH (transport security only) + var sharedSecret = SecureChannelModel.DeriveSharedSecret(ecdhKeyPair, desktopEcdhPublicKey); + + // Generate our session nonce + var mobileNonce = new byte[16]; + RandomNumberGenerator.Fill(mobileNonce); + + // Derive session key from ECDH secret + both nonces + var combinedNonce = new byte[desktopNonce.Length + mobileNonce.Length]; + desktopNonce.CopyTo(combinedNonce, 0); + mobileNonce.CopyTo(combinedNonce, desktopNonce.Length); + + _secureChannel = new SecureChannelModel(sharedSecret, combinedNonce); + + // Send response with our ECDH public key + var response = ProtocolSerializer.CreateSecureSessionAccepted(mobileNonce, myPublicKey); + await device.SendMessageAsync(response, cancellationToken); + } + + private async Task HandleSecureAuthRequestAsync(ConnectedDevice device, byte[] message, + CancellationToken cancellationToken) + { + if (_secureChannel == null || _currentCredential == null) + { + await device.SendMessageAsync(ProtocolSerializer.CreateAuthenticationRejected("No session"), cancellationToken); + return; + } + + try + { + // Decrypt request + var encryptedPayload = message.AsSpan(1).ToArray(); + var decryptedPayload = _secureChannel.Decrypt(encryptedPayload); + + // Parse request (persistent challenge from desktop) + using var ms = new MemoryStream(decryptedPayload); + using var reader = new BinaryReader(ms); + + var credentialId = reader.ReadString(); + var challengeLength = reader.ReadInt32(); + var persistentChallenge = reader.ReadBytes(challengeLength); + var timestamp = reader.ReadInt64(); + + // Validate timestamp (for replay protection) + var requestTime = DateTimeOffset.FromUnixTimeSeconds(timestamp); + var age = DateTimeOffset.UtcNow - requestTime; + + if (Math.Abs(age.TotalSeconds) > CHALLENGE_VALIDITY_SECONDS) + { + await device.SendMessageAsync(ProtocolSerializer.CreateAuthenticationRejected("Request expired"), cancellationToken); + return; + } + + // Verify credential matches + if (_currentCredential.CredentialId != credentialId) + { + await device.SendMessageAsync(ProtocolSerializer.CreateAuthenticationRejected("Credential mismatch"), cancellationToken); + return; + } + + // Verify the persistent challenge matches what we have stored + var storedChallenge = _currentCredential.Challenge; + if (storedChallenge == null || !persistentChallenge.SequenceEqual(storedChallenge)) + { + await device.SendMessageAsync(ProtocolSerializer.CreateAuthenticationRejected("Challenge mismatch"), cancellationToken); + return; + } + + // Create a TaskCompletionSource for user confirmation + _authConfirmationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var authInfo = new AuthenticationRequestModel(_currentCredential.VaultName, _currentCredential.MachineName, _currentCredential.DisplayName); + + // Notify UI about auth request (could require biometric) + AuthenticationRequested?.Invoke(this, authInfo); + + // Wait for user to accept or reject + bool userConfirmed; + using (cancellationToken.Register(() => _authConfirmationTcs.TrySetCanceled())) + { + userConfirmed = await _authConfirmationTcs.Task; + } + + if (!userConfirmed) + { + await device.SendMessageAsync(ProtocolSerializer.CreateAuthenticationRejected("User rejected"), cancellationToken); + return; + } + + // Decrypt HMAC key using stored encryption key + var encryptionKey = await _credentialStoreModel.GetEncryptionKeyAsync(_currentCredential.PairingId); + if (encryptionKey == null) + { + await device.SendMessageAsync(ProtocolSerializer.CreateAuthenticationRejected("Key error"), cancellationToken); + return; + } + + try + { + _currentCredential.DecryptHmacKey(encryptionKey); + + // Compute HMAC over the challenge data + var challengeData = BuildChallengeData(credentialId, persistentChallenge); + var hmacResult = _currentCredential.ComputeHmac(challengeData); + + // Encrypt and send response + var encryptedHmac = _secureChannel.Encrypt(hmacResult); + await device.SendMessageAsync(encryptedHmac, MessageType.SecureAuthResponse, cancellationToken); + + // Notify UI that authentication completed successfully + AuthenticationCompleted?.Invoke(this, EventArgs.Empty); + } + finally + { + // Clear decrypted key from memory + _currentCredential.ClearDecryptedKey(); + CryptographicOperations.ZeroMemory(encryptionKey); + } + } + catch (CryptographicException) + { + await SafetyHelpers.NoFailureAsync(async () => + await device.SendMessageAsync(ProtocolSerializer.CreateAuthenticationRejected("Decryption failed"), cancellationToken)); + } + } + + /// + /// Builds the data for HMAC computation. + /// Must match desktop's BuildChallengeData exactly. + /// + private static byte[] BuildChallengeData(string credentialId, byte[] persistentChallenge) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write(Encoding.UTF8.GetBytes(credentialId)); + writer.Write(persistentChallenge); + + return ms.ToArray(); + } + + #endregion + + /// + /// Cleans up cryptographic session state. + /// + private void CleanupSessionState() + { + SafetyHelpers.NoFailure(() => _secureChannel?.Dispose()); + _secureChannel = null; + + _pairingConfirmationTcs = null; + _authConfirmationTcs = null; + _currentCredential = null; + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) + return; + + _disposed = true; + } + + _connectionListener.ConnectionAccepted -= OnConnectionAccepted; + StopListening(); + CleanupSessionState(); + _connectionListener.Dispose(); + + SafetyHelpers.NoFailure(() => _serviceCts?.Dispose()); + + Disconnected = null; + PairingRequested = null; + EnrollmentCompleted = null; + VerificationCodeReady = null; + AuthenticationRequested = null; + AuthenticationCompleted = null; + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Services/IDeviceLinkService.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Services/IDeviceLinkService.cs new file mode 100644 index 000000000..28459fa02 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/Services/IDeviceLinkService.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.PhoneLink.Models; +using SecureFolderFS.Sdk.PhoneLink.ViewModels; + +namespace SecureFolderFS.Sdk.PhoneLink.Services +{ + public interface IDeviceLinkService : IDisposable + { + event EventHandler? EnrollmentCompleted; + event EventHandler? PairingRequested; + event EventHandler? AuthenticationRequested; + event EventHandler? VerificationCodeReady; + event EventHandler? Disconnected; + event EventHandler? AuthenticationCompleted; + + bool IsListening { get; } + + Task StartListeningAsync(CancellationToken cancellationToken = default); + + void StopListening(); + + void ConfirmPairingRequest(bool value); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/ViewModels/CredentialViewModel.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/ViewModels/CredentialViewModel.cs new file mode 100644 index 000000000..92cfa36b2 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/ViewModels/CredentialViewModel.cs @@ -0,0 +1,197 @@ +using System; +using System.ComponentModel; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Serialization; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SecureFolderFS.Sdk.PhoneLink.ViewModels +{ + /// + /// Represents a secure credential with an encrypted signing key. + /// The signing key is encrypted using the pairing shared secret. + /// + [Serializable] + [Bindable(true)] + public sealed partial class CredentialViewModel : ObservableObject, IDisposable + { + [JsonIgnore] private byte[]? _decryptedHmacKey; + [JsonIgnore] private bool _disposed; + + [JsonIgnore] private const int HmacKeySize = 32; + + /// + /// The Credential ID (CID) that binds this credential to a desktop vault. + /// + [JsonPropertyName("cid")] [ObservableProperty] private string? _CredentialId; + + /// + /// Human-readable name for this credential. + /// + [JsonPropertyName("displayName")] [ObservableProperty] private string? _DisplayName; + + /// + /// Name of the vault this credential is bound to. + /// + [JsonPropertyName("vaultName")] [ObservableProperty] private string? _VaultName; + + /// + /// Name of the desktop device this credential is paired with. + /// + [JsonPropertyName("machineName")] [ObservableProperty] private string? _MachineName; + + /// + /// Unique pairing identifier shared with desktop. + /// + [JsonPropertyName("pairingId")] [ObservableProperty] private string? _PairingId; + + /// + /// When this credential was created/enrolled. + /// + [JsonPropertyName("creationDate")] [ObservableProperty] private DateTime? _CreatedAt; + + /// + /// The persistent challenge that must be signed during authentication. + /// This is stored during enrollment and verified during each authentication. + /// + [JsonPropertyName("challenge")] [ObservableProperty] private byte[]? _Challenge; + + /// + /// Unique ID for this credential record. + /// + public string? Id { get; set; } + + /// + /// The public signing key (exported after enrollment). + /// + [JsonPropertyName("c_hmacKey")] + public byte[]? EncryptedHmacKey { get; set; } + + /// + /// Nonce used for encrypting the signing key. + /// + [JsonPropertyName("encryptionNonce")] + public byte[]? EncryptionNonce { get; set; } + + /// + /// Auth tag from encrypting the signing key. + /// + [JsonPropertyName("encryptionTag")] + public byte[]? EncryptionTag { get; set; } + + /// + /// Whether this credential has been enrolled (has signing key). + /// + [JsonIgnore] + public bool IsEnrolled => EncryptedHmacKey is not null && EncryptedHmacKey.Length > 0; + + /// + /// Generates a new HMAC key and encrypts it with the encryption key. + /// Called during enrollment. + /// + /// The key derived from pairing session. + public void GenerateAndEncryptHmacKey(byte[] encryptionKey) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Generate new HMAC key (256-bit) + var hmacKey = new byte[HmacKeySize]; + RandomNumberGenerator.Fill(hmacKey); + + try + { + // Encrypt the HMAC key + EncryptHmacKey(hmacKey, encryptionKey); + } + finally + { + // Clear unencrypted key + CryptographicOperations.ZeroMemory(hmacKey); + } + } + + private void EncryptHmacKey(byte[] hmacKey, byte[] encryptionKey) + { + // Generate random nonce + EncryptionNonce = new byte[12]; + RandomNumberGenerator.Fill(EncryptionNonce); + + // Encrypt using the provided encryption key directly + using var aes = new AesGcm(encryptionKey, 16); + EncryptedHmacKey = new byte[hmacKey.Length]; + EncryptionTag = new byte[16]; + + aes.Encrypt(EncryptionNonce, hmacKey, EncryptedHmacKey, EncryptionTag); + } + + /// + /// Decrypts the HMAC key so it can be used for authentication. + /// Must be called before ComputeHmac. + /// + /// The encryption key (already derived). + public void DecryptHmacKey(byte[] encryptionKey) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (EncryptedHmacKey.Length == 0) + throw new InvalidOperationException("No encrypted HMAC key available"); + + try + { + // Decrypt the HMAC key + using var aes = new AesGcm(encryptionKey, 16); + _decryptedHmacKey = new byte[EncryptedHmacKey.Length]; + + aes.Decrypt(EncryptionNonce, EncryptedHmacKey, EncryptionTag, _decryptedHmacKey); + } + catch (CryptographicException ex) + { + throw new InvalidOperationException("Failed to decrypt HMAC key", ex); + } + } + + /// + /// Computes HMAC-SHA256 over the given data using the decrypted HMAC key. + /// HMAC is deterministic: same key + same data = same result. + /// + /// The data to authenticate. + /// The HMAC (32 bytes for SHA256). + public byte[] ComputeHmac(byte[] data) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_decryptedHmacKey == null) + throw new InvalidOperationException("HMAC key not available. Call DecryptHmacKey first."); + + return HMACSHA256.HashData(_decryptedHmacKey, data); + } + + /// + /// Clears the decrypted HMAC key from memory. + /// Should be called after authentication is complete. + /// + public void ClearDecryptedKey() + { + if (_decryptedHmacKey != null) + { + CryptographicOperations.ZeroMemory(_decryptedHmacKey); + _decryptedHmacKey = null; + } + } + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + ClearDecryptedKey(); + + if (EncryptedHmacKey.Length > 0) + CryptographicOperations.ZeroMemory(EncryptedHmacKey); + + if (Challenge is { Length: > 0 }) + CryptographicOperations.ZeroMemory(Challenge); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.PhoneLink/ViewModels/PairingRequestViewModel.cs b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/ViewModels/PairingRequestViewModel.cs new file mode 100644 index 000000000..dde0cb78f --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.PhoneLink/ViewModels/PairingRequestViewModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SecureFolderFS.Sdk.PhoneLink.ViewModels +{ + [Bindable(true)] + public sealed partial class PairingRequestViewModel : ObservableObject + { + [ObservableProperty] private string _DesktopName; + [ObservableProperty] private string _CredentialId; + [ObservableProperty] private string _VerificationCode; + + public PairingRequestViewModel(string desktopName, string credentialId, string verificationCode) + { + DesktopName = desktopName; + CredentialId = credentialId; + VerificationCode = verificationCode; + } + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs index 93e1ce43f..4bbc97d4d 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/Database/BatchDatabaseModel.cs @@ -209,11 +209,15 @@ private async Task EnsureSettingsFolderAsync(CancellationToken cancellationToken _databaseFolder ??= (IModifiableFolder?)await _settingsFolder.CreateFolderAsync(_folderName, false, cancellationToken); } - public sealed record SettingValue(Type Type, object? Data, bool WasModified = true) + public sealed record SettingValue(Type Type, object? Data, bool WasModified = true) : IChangeTracker { - public object? Data { get; set; } = Data; - + /// public bool WasModified { get; set; } = WasModified; + + /// + /// Gets or sets the data associated with a setting. + /// + public object? Data { get; set; } = Data; } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/HealthModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/HealthModel.cs index 68d77c15e..ecaa7e784 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/HealthModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/HealthModel.cs @@ -23,6 +23,7 @@ public sealed partial class HealthModel : IHealthModel, IProgress private readonly List _scannedFolders; private readonly ProgressModel _progress; private readonly IAsyncValidator<(IFolder, IProgress?), IResult>? _structureValidator; + private readonly IAsyncValidator? _fileContentValidator; private int _updateCount; private int _updateInterval; private volatile int _totalFilesScanned; @@ -31,7 +32,7 @@ public sealed partial class HealthModel : IHealthModel, IProgress /// public event EventHandler? IssueFound; - public HealthModel(IFolderScanner folderScanner, ProgressModel progress, IAsyncValidator<(IFolder, IProgress?), IResult>? structureValidator) + public HealthModel(IFolderScanner folderScanner, ProgressModel progress, IAsyncValidator<(IFolder, IProgress?), IResult>? structureValidator, IAsyncValidator? fileContentValidator = null) { ServiceProvider = DI.Default; _scannedFiles = new(); @@ -39,6 +40,7 @@ public HealthModel(IFolderScanner folderScanner, ProgressModel pr _folderScanner = folderScanner; _progress = progress; _structureValidator = structureValidator; + _fileContentValidator = fileContentValidator; } /// @@ -82,9 +84,13 @@ await Task.Run(async () => ReportProgress(_progress); await Task.Delay(750, cancellationToken); - // Begin scanning + // Begin scanning structure await ScanStructureAsync(cancellationToken).ConfigureAwait(false); + // Scan file contents if requested + if (includeFileContents) + await ScanFileContentsAsync(cancellationToken).ConfigureAwait(false); + // Report final progress ReportProgress(_progress); await Task.Delay(1500, cancellationToken); @@ -169,6 +175,36 @@ async Task ScanFolderAsync(IChildFolder folder, CancellationToken token) } } + private async Task ScanFileContentsAsync(CancellationToken cancellationToken) + { + if (_fileContentValidator is null) + return; + + if (Constants.Widgets.Health.IS_SCANNING_PARALLELIZED) + { + var tasks = new List(_scannedFiles.Count); + foreach (var file in _scannedFiles) + tasks.Add(ScanFileContentAsync(file, cancellationToken)); + + await Task.WhenAll(tasks).ConfigureAwait(false); + } + else + { + foreach (var file in _scannedFiles) + await ScanFileContentAsync(file, cancellationToken).ConfigureAwait(false); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + async Task ScanFileContentAsync(IChildFile file, CancellationToken token) + { + if (VaultService.IsNameReserved(file.Name)) + return; + + var result = await _fileContentValidator.ValidateResultAsync(file, token).ConfigureAwait(false); + Report(result); + } + } + private void ReportProgress(ProgressModel progress) { if (Constants.Widgets.Health.ARE_UPDATES_OPTIMIZED) @@ -187,6 +223,7 @@ private void ReportProgress(ProgressModel progress) /// public void Dispose() { + IssueFound = null; _scannedFiles.Clear(); _scannedFolders.Clear(); } diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs index e9bcf00ea..47ffaa32c 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs @@ -132,13 +132,22 @@ protected override void InsertItem(int index, IVaultModel item) VaultConfigurations.PersistedVaults.Insert(index, item.DataModel); // Add default widgets for vault - VaultWidgets.SetForVault(item.DataModel.PersistableId, new List() + var widgetList = new List() { - new(Constants.Widgets.HEALTH_WIDGET_ID), - ApplicationService.IsDesktop - ? new(Constants.Widgets.GRAPHS_WIDGET_ID) - : new(Constants.Widgets.AGGREGATED_DATA_WIDGET_ID) - }); + new(Constants.Widgets.HEALTH_WIDGET_ID) + }; + if (ApplicationService.IsDesktop) + widgetList.Add(new(Constants.Widgets.GRAPHS_WIDGET_ID)); + else + widgetList.Add(new(Constants.Widgets.AGGREGATED_DATA_WIDGET_ID)); + +#if DEBUG + // TODO: Testing on desktop + widgetList.Insert(1, new(Constants.Widgets.AGGREGATED_DATA_WIDGET_ID)); +#endif + + // Set widgets for vault + VaultWidgets.SetForVault(item.DataModel.PersistableId, widgetList); // Add to cache base.InsertItem(index, item); diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultModel.cs index 52f5ef82c..eee69c1fe 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/VaultModel.cs @@ -17,7 +17,7 @@ namespace SecureFolderFS.Sdk.AppModels [Inject] public sealed partial class VaultModel : IVaultModel { - private readonly IRemoteResource? _remoteVault; + public IRemoteResource? Inner { get; } /// public bool IsRemote { get; } @@ -44,7 +44,7 @@ public VaultModel(IRemoteResource remoteVault, VaultDataModel dataModel ServiceProvider = DI.Default; IsRemote = true; VaultFolder = folder; - _remoteVault = remoteVault; + Inner = remoteVault; DataModel = dataModel; } @@ -60,11 +60,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = de if (VaultFolder is not null) return VaultFolder; - ArgumentNullException.ThrowIfNull(_remoteVault); + ArgumentNullException.ThrowIfNull(Inner); ArgumentNullException.ThrowIfNull(DataModel.PersistableId); // Connect to remote vault - var rootFolder = await _remoteVault.ConnectAsync(cancellationToken); + var rootFolder = await Inner.ConnectAsync(cancellationToken); // Try getting by relative path (crawl by name) VaultFolder = await rootFolder.TryGetItemByRelativePathOrSelfAsync(DataModel.PersistableId, cancellationToken) as IFolder; @@ -99,7 +99,7 @@ public void Dispose() StateChanged?.Invoke(this, new VaultChangedEventArgs(false)); } - _remoteVault?.Dispose(); + Inner?.Dispose(); } /// @@ -111,8 +111,8 @@ public async ValueTask DisposeAsync() StateChanged?.Invoke(this, new VaultChangedEventArgs(false)); } - if (_remoteVault is not null) - await _remoteVault.DisposeAsync(); + if (Inner is not null) + await Inner.DisposeAsync(); } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/WidgetModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/WidgetModel.cs index 5ffc8cf38..4f90efc77 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/WidgetModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/WidgetModel.cs @@ -11,28 +11,30 @@ namespace SecureFolderFS.Sdk.AppModels public sealed class WidgetModel : IWidgetModel { private readonly IPersistable _widgetsStore; - private readonly WidgetDataModel _widgetDataModel; /// public string WidgetId { get; } + /// + public WidgetDataModel DataModel { get; } + public WidgetModel(string widgetId, IPersistable widgetsStore, WidgetDataModel widgetDataModel) { WidgetId = widgetId; + DataModel = widgetDataModel; _widgetsStore = widgetsStore; - _widgetDataModel = widgetDataModel; } /// public Task GetWidgetDataAsync(CancellationToken cancellationToken = default) { - return Task.FromResult(_widgetDataModel.WidgetsData); + return Task.FromResult(DataModel.WidgetsData); } /// public Task SetWidgetDataAsync(string? value, CancellationToken cancellationToken = default) { - _widgetDataModel.WidgetsData = value; + DataModel.WidgetsData = value; return _widgetsStore.TrySaveAsync(cancellationToken); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/WidgetsCollectionModel.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/WidgetsCollectionModel.cs index db23fab73..8c3409340 100644 --- a/src/Sdk/SecureFolderFS.Sdk/AppModels/WidgetsCollectionModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/WidgetsCollectionModel.cs @@ -32,6 +32,31 @@ public WidgetsCollectionModel(string persistableId) _widgets = new(); } + /// + public Task InitAsync(CancellationToken cancellationToken = default) + { + // await VaultWidgets.InitAsync(cancellationToken); + // VaultWidgets already loaded by VaultCollectionModel // TODO: Load here as well because we shouldn't rely on the implementation + + // Clear previous widgets + _widgets.Clear(); + + var widgets = VaultWidgets.GetForVault(_id); + if (widgets is null) + return Task.CompletedTask; + + foreach (var item in widgets) + _widgets.Add(new WidgetModel(item.WidgetId, VaultWidgets, item)); + + return Task.CompletedTask; + } + + /// + public Task SaveAsync(CancellationToken cancellationToken = default) + { + return VaultWidgets.SaveAsync(cancellationToken); + } + /// public bool AddWidget(string widgetId) { @@ -78,34 +103,23 @@ public bool RemoveWidget(string widgetId) } /// - public IEnumerable GetWidgets() + public void UpdateOrder(IList orderedWidgets) { - return _widgets; - } + // Create a new list of widgets + var orderedWidgetIds = orderedWidgets.Select(w => w.DataModel).ToList(); - /// - public Task InitAsync(CancellationToken cancellationToken = default) - { - // await VaultWidgets.InitAsync(cancellationToken); - // VaultWidgets already loaded by VaultCollectionModel // TODO: Load here as well because we shouldn't rely on the implementation + // Update VaultWidgets with the new order + VaultWidgets.SetForVault(_id, orderedWidgetIds); - // Clear previous widgets + // Update the cache to match the new order _widgets.Clear(); - - var widgets = VaultWidgets.GetForVault(_id); - if (widgets is null) - return Task.CompletedTask; - - foreach (var item in widgets) - _widgets.Add(new WidgetModel(item.WidgetId, VaultWidgets, item)); - - return Task.CompletedTask; + _widgets.AddRange(orderedWidgets); } /// - public Task SaveAsync(CancellationToken cancellationToken = default) + public IEnumerable GetWidgets() { - return VaultWidgets.SaveAsync(cancellationToken); + return _widgets; } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/Constants.cs b/src/Sdk/SecureFolderFS.Sdk/Constants.cs index cbb9e0ab5..1b41a5152 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Constants.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Constants.cs @@ -2,6 +2,12 @@ { public static class Constants { + public static class Settings + { + public const string EXPORTED_ARCHIVE_FILENAME = $"{nameof(SecureFolderFS)} Settings"; + public const string EXPORTED_ARCHIVE_EXTENSION = ".appsettings"; + } + public static class Widgets { public const string HEALTH_WIDGET_ID = "health_widget"; @@ -46,13 +52,6 @@ This will open a virtual storage directory where the files you add will be autom """; } - public static class Sizes - { - public const long KILOBYTE = 1024; - public const long MEGABYTE = KILOBYTE * 1024; - public const long GIGABYTE = MEGABYTE * 1024; - } - public static class IntegrationPermissions { public const string ENUMERATE_VAULTS = "enumerate_vaults"; // List all added vaults and get basic info diff --git a/src/Sdk/SecureFolderFS.Sdk/DataModels/VaultShortcutDataModel.cs b/src/Sdk/SecureFolderFS.Sdk/DataModels/VaultShortcutDataModel.cs new file mode 100644 index 000000000..bec54e49f --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/DataModels/VaultShortcutDataModel.cs @@ -0,0 +1,13 @@ +using System; + +namespace SecureFolderFS.Sdk.DataModels +{ + /// + /// Represents the data stored in a .sfvault shortcut file. + /// + /// Gets the persistable ID of the vault folder. + /// Gets the display name of the vault. + [Serializable] + public sealed record VaultShortcutDataModel(string? PersistableId, string? VaultName); +} + diff --git a/src/Sdk/SecureFolderFS.Sdk/Enums/TransferType.cs b/src/Sdk/SecureFolderFS.Sdk/Enums/TransferType.cs index f73d82494..0298d6c86 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Enums/TransferType.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Enums/TransferType.cs @@ -4,6 +4,8 @@ public enum TransferType { Copy = 0, Move = 1, - Select = 2 + Select = 2, + Save = 4, + Load = 8 } } diff --git a/src/Sdk/SecureFolderFS.Sdk/EventArguments/CredentialsProvidedEventArgs.cs b/src/Sdk/SecureFolderFS.Sdk/EventArguments/CredentialsProvidedEventArgs.cs index 5f87db2be..1873ad0b8 100644 --- a/src/Sdk/SecureFolderFS.Sdk/EventArguments/CredentialsProvidedEventArgs.cs +++ b/src/Sdk/SecureFolderFS.Sdk/EventArguments/CredentialsProvidedEventArgs.cs @@ -7,12 +7,12 @@ namespace SecureFolderFS.Sdk.EventArguments /// /// Event arguments for authentication provided events. /// - public sealed class CredentialsProvidedEventArgs(IKey authentication, TaskCompletionSource? taskCompletion) : EventArgs + public sealed class CredentialsProvidedEventArgs(IKeyUsage authentication, TaskCompletionSource? taskCompletion) : EventArgs { /// /// Gets the authentication that was provided. /// - public IKey Authentication { get; } = authentication; + public IKeyUsage Authentication { get; } = authentication; /// /// Gets the optional to notify when the unlock operation is finished. diff --git a/src/Sdk/SecureFolderFS.Sdk/EventArguments/CredentialsProvisionChangedEventArgs.cs b/src/Sdk/SecureFolderFS.Sdk/EventArguments/CredentialsProvisionChangedEventArgs.cs index 3344f996d..218262e29 100644 --- a/src/Sdk/SecureFolderFS.Sdk/EventArguments/CredentialsProvisionChangedEventArgs.cs +++ b/src/Sdk/SecureFolderFS.Sdk/EventArguments/CredentialsProvisionChangedEventArgs.cs @@ -6,16 +6,16 @@ namespace SecureFolderFS.Sdk.EventArguments /// /// Event arguments for credentials provision changed events. /// - public sealed class CredentialsProvisionChangedEventArgs(IKey clearProvision, IKey signedProvision) : EventArgs + public sealed class CredentialsProvisionChangedEventArgs(IKeyBytes clearProvision, IKeyBytes signedProvision) : EventArgs { /// /// Gets the clear credentials provision representation. /// - public IKey ClearProvision { get; } = clearProvision; + public IKeyBytes ClearProvision { get; } = clearProvision; /// /// Gets the signed credentials provision representation using the user-provided credentials. /// - public IKey SignedProvision { get; } = signedProvision; + public IKeyBytes SignedProvision { get; } = signedProvision; } } diff --git a/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs b/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs index cf7f2e379..2b4adf3a2 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs @@ -1,4 +1,5 @@ -using System.Runtime.CompilerServices; +using System; +using System.Runtime.CompilerServices; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared; using SecureFolderFS.Shared.Helpers; @@ -19,7 +20,11 @@ public static class LocalizationExtensions public static string ToLocalized(this string resourceKey, ILocalizationService? localizationService = null) { localizationService = GetLocalizationService(localizationService); - return localizationService?.GetResource(resourceKey) ?? $"{{{resourceKey}}}"; + var resource = localizationService?.GetResource(resourceKey); + if (string.IsNullOrEmpty(resource)) + return $"{{{resourceKey}}}"; + + return resource.Replace("\\n", Environment.NewLine); [MethodImpl(MethodImplOptions.AggressiveInlining)] static ILocalizationService? GetLocalizationService(ILocalizationService? fallback) @@ -40,5 +45,19 @@ public static string ToLocalized(this string resourceKey, params object?[] inter var localized = ToLocalized(resourceKey); return SafetyHelpers.NoFailureResult(() => string.Format(localized, interpolate)) ?? localized; } + + /// + /// Converts the specified resource key to its localized string representation using the provided localization service + /// and formats it with the provided interpolation parameters. + /// + /// The key representing the resource to be localized and formatted. + /// The to use. + /// An array of parameters used for string interpolation in the localized string representation. + /// A formatted and localized string corresponding to the specified resource key. If localization or formatting fails, the resource key surrounded by curly braces ("{...}") is returned. + public static string ToLocalized(this string resourceKey, ILocalizationService localizationService, params object?[] interpolate) + { + var localized = ToLocalized(resourceKey, localizationService); + return SafetyHelpers.NoFailureResult(() => string.Format(localized, interpolate)) ?? localized; + } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationServiceExtensions.cs b/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationServiceExtensions.cs index ef58338cf..baf9bcfd6 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationServiceExtensions.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationServiceExtensions.cs @@ -6,13 +6,23 @@ namespace SecureFolderFS.Sdk.Extensions { public static class LocalizationServiceExtensions { + /// + /// Converts a to a localized, human-readable string representation. + /// + /// The localization service used to retrieve localized strings and culture information. + /// The date and time to localize. + /// A localized string representation of the date. public static string LocalizeDate(this ILocalizationService localizationService, DateTime dateTime) { var cultureInfo = localizationService.CurrentCulture; + var daysAgo = (DateTime.Today - dateTime.Date).Days; var dateString = dateTime switch { _ when dateTime.Year == 1 => "Unspecified", - _ when dateTime.Date == DateTime.Today => "DateToday".ToLocalized(dateTime.ToString("t", cultureInfo)), + _ when dateTime.Date == DateTime.Today => "DateToday".ToLocalized(localizationService, interpolate: dateTime.ToString("t", cultureInfo)), + _ when daysAgo == 1 => "DateYesterday".ToLocalized(localizationService, interpolate: dateTime.ToString("t", cultureInfo)), + _ when daysAgo is >= 2 and <= 6 => "DateDaysAgo".ToLocalized(localizationService, interpolate: daysAgo.ToString()), + _ when daysAgo is >= 7 and < 14 => "DateWeekAgo".ToLocalized(localizationService), _ => null }; diff --git a/src/Sdk/SecureFolderFS.Sdk/Extensions/NavigationExtensions.cs b/src/Sdk/SecureFolderFS.Sdk/Extensions/NavigationExtensions.cs index 40e81a7ab..c39c0e7d0 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Extensions/NavigationExtensions.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Extensions/NavigationExtensions.cs @@ -44,7 +44,7 @@ public static async Task ForgetNavigateCurrentViewAsync(this INavigationSe }); } - public static async Task ForgetNavigateSpecificViewAsync(this INavigationService navigationService, IViewDesignation view, Func viewFinder) + public static async Task ForgetNavigateSpecificViewAsync(this INavigationService navigationService, IViewDesignation view, Func viewFinder, bool addViewIfMissing = false) { return await ForgetNavigateViewAsync(navigationService, view, () => { @@ -54,10 +54,10 @@ public static async Task ForgetNavigateSpecificViewAsync(this INavigationS navigationService.Views.Remove(targetView); return targetView; - }, navigationService.CurrentView is null || viewFinder(navigationService.CurrentView)); + }, navigationService.CurrentView is null || viewFinder(navigationService.CurrentView), addViewIfMissing); } - private static async Task ForgetNavigateViewAsync(this INavigationService navigationService, IViewDesignation view, Func viewForgetter, bool shouldTriggerNavigation = true) + private static async Task ForgetNavigateViewAsync(this INavigationService navigationService, IViewDesignation view, Func viewForgetter, bool shouldTriggerNavigation = true, bool addViewIfMissing = false) { var navigated = false; IViewDesignation? currentView = null; @@ -68,7 +68,7 @@ private static async Task ForgetNavigateViewAsync(this INavigationService if (!shouldTriggerNavigation) { // Silently replace the removed view without triggering navigation - if (currentView is not null) + if (currentView is not null || addViewIfMissing) navigationService.Views.Add(view); return false; diff --git a/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs b/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs index df29dcab8..bb5b39990 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs @@ -21,7 +21,7 @@ public static async Task HideAsync(this TransferViewModel transferViewModel) { transferViewModel.IsPickingFolder = false; transferViewModel.IsVisible = false; - await Task.Delay(400); + await Task.Delay(350); transferViewModel.IsProgressing = false; } @@ -79,5 +79,68 @@ public static async Task TransferAsync( await Task.Delay(1000, CancellationToken.None); await transferViewModel.HideAsync(); } + + public static async Task PerformOperationAsync(this TransferViewModel transferViewModel, Func operation, CancellationToken cancellationToken = default) + { + var uiShown = false; + var showUiCts = new CancellationTokenSource(); + + try + { + transferViewModel.Title = transferViewModel.TransferType switch + { + TransferType.Save => "Saving".ToLocalized(), + TransferType.Load => "Loading".ToLocalized(), + _ => string.Empty, + }; + transferViewModel.CanCancel = cancellationToken != CancellationToken.None; + transferViewModel.IsProgressing = true; + + // Start a task that will show the UI after a delay if the operation is still running + _ = ShowUiAfterDelayAsync(500, showUiCts.Token); + + // Run the operation and wait for it to complete + var operationTask = operation(cancellationToken); + await operationTask; + + // Cancel the delayed UI show if operation completed quickly + await showUiCts.CancelAsync(); + if (uiShown) + { + transferViewModel.Title = "TransferDone".ToLocalized(); + await Task.Delay(300); // Allow user to see the "Done" message + } + } + catch (OperationCanceledException) + { + await showUiCts.CancelAsync(); + if (uiShown) + { + transferViewModel.CanCancel = false; + transferViewModel.Title = "Cancelling".ToLocalized(); + } + } + finally + { + showUiCts.Dispose(); + await HideAsync(transferViewModel); + } + + return; + + async Task ShowUiAfterDelayAsync(int delayMs, CancellationToken ct) + { + try + { + await Task.Delay(delayMs, ct); + uiShown = true; + transferViewModel.IsVisible = true; + } + catch (OperationCanceledException) + { + // Operation completed before delay, don't show UI + } + } + } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/Helpers/CollectionReorderHelper.cs b/src/Sdk/SecureFolderFS.Sdk/Helpers/CollectionReorderHelper.cs new file mode 100644 index 000000000..81837d841 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/Helpers/CollectionReorderHelper.cs @@ -0,0 +1,30 @@ +using System; + +namespace SecureFolderFS.Sdk.Helpers +{ + public sealed class CollectionReorderHelper : IDisposable + where T : class + { + private T? _previous; + + public event EventHandler? Reordered; + + public void RegisterRemove(T removed) + { + _previous = removed; + } + + public void RegisterAdd(T added) + { + if (_previous == added) + Reordered?.Invoke(this, added); + } + + /// + public void Dispose() + { + _previous = null; + Reordered = null; + } + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk/Helpers/RecycleBinHelpers.cs b/src/Sdk/SecureFolderFS.Sdk/Helpers/RecycleBinHelpers.cs index 9ae2ddc6d..1b65141c1 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Helpers/RecycleBinHelpers.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Helpers/RecycleBinHelpers.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.ViewModels.Controls; -using static SecureFolderFS.Sdk.Constants.Sizes; +using static SecureFolderFS.Storage.Constants.Sizes; namespace SecureFolderFS.Sdk.Helpers { diff --git a/src/Sdk/SecureFolderFS.Sdk/Helpers/ValidationHelpers.cs b/src/Sdk/SecureFolderFS.Sdk/Helpers/ValidationHelpers.cs index 89deaace9..6c31045af 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Helpers/ValidationHelpers.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Helpers/ValidationHelpers.cs @@ -64,23 +64,21 @@ string GetSelectedLocation() } } - public static async Task> ValidateExistingVault(IFolder vaultFolder, CancellationToken cancellationToken) + public static async Task> ValidateExistingVault( + IFolder vaultFolder, + CancellationToken cancellationToken) { var vaultService = DI.Service(); var validationResult = await vaultService.VaultValidator.TryValidateAsync(vaultFolder, cancellationToken); + if (validationResult.Successful) + return new MessageResult(Severity.Success, "SelectedValidVault".ToLocalized()); - if (!validationResult.Successful) + return validationResult.Exception switch { - if (validationResult.Exception is NotSupportedException) - { - // Allow unsupported vaults to be migrated - return new MessageResult(Severity.Warning, "SelectedMayNotBeSupported".ToLocalized()); - } - - return new MessageResult(Severity.Critical, "SelectedInvalidVault".ToLocalized(), false); - } - - return new MessageResult(Severity.Success, "SelectedValidVault".ToLocalized()); + NotSupportedException => new MessageResult(Severity.Warning, "SelectedMayNotBeSupported".ToLocalized()), + TimeoutException => new MessageResult(Severity.Warning, "OperationTimedOut".ToLocalized(), false), + _ => new MessageResult(Severity.Critical, "SelectedInvalidVault".ToLocalized(), false) + }; } public static async Task> ValidateNewVault(IFolder vaultFolder, CancellationToken cancellationToken) diff --git a/src/Sdk/SecureFolderFS.Sdk/Messages/VaultSelectionRequestedMessage.cs b/src/Sdk/SecureFolderFS.Sdk/Messages/VaultSelectionRequestedMessage.cs new file mode 100644 index 000000000..426173a90 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/Messages/VaultSelectionRequestedMessage.cs @@ -0,0 +1,16 @@ +using SecureFolderFS.Sdk.Models; + +namespace SecureFolderFS.Sdk.Messages +{ + /// + /// A message requesting that a specific vault be selected in the UI. + /// + public sealed class VaultSelectionRequestedMessage(IVaultModel vaultModel) + { + /// + /// Gets the vault that should be selected. + /// + public IVaultModel VaultModel { get; } = vaultModel; + } +} + diff --git a/src/Sdk/SecureFolderFS.Sdk/Messages/VaultShortcutActivatedMessage.cs b/src/Sdk/SecureFolderFS.Sdk/Messages/VaultShortcutActivatedMessage.cs new file mode 100644 index 000000000..f258cb09d --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/Messages/VaultShortcutActivatedMessage.cs @@ -0,0 +1,21 @@ +using SecureFolderFS.Sdk.DataModels; + +namespace SecureFolderFS.Sdk.Messages +{ + /// + /// A message notifying that a vault shortcut file was activated. + /// + public sealed class VaultShortcutActivatedMessage(VaultShortcutDataModel shortcutData, string? filePath = null) + { + /// + /// Gets the shortcut data from the activated file. + /// + public VaultShortcutDataModel ShortcutData { get; } = shortcutData; + + /// + /// Gets the path of the shortcut file that was activated. + /// + public string? FilePath { get; } = filePath; + } +} + diff --git a/src/Sdk/SecureFolderFS.Sdk/Models/IVaultModel.cs b/src/Sdk/SecureFolderFS.Sdk/Models/IVaultModel.cs index 304a06e5e..6e9e60766 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Models/IVaultModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Models/IVaultModel.cs @@ -1,5 +1,4 @@ using System; -using System.ComponentModel; using OwlCore.Storage; using SecureFolderFS.Sdk.DataModels; using SecureFolderFS.Shared.ComponentModel; @@ -9,7 +8,7 @@ namespace SecureFolderFS.Sdk.Models /// /// A model that represents a vault. /// - public interface IVaultModel : INotifyStateChanged, IRemoteResource, IEquatable, IEquatable, ISavePersistence + public interface IVaultModel : INotifyStateChanged, IWrapper?>, IRemoteResource, IEquatable, IEquatable, ISavePersistence { /// /// Gets a value indicating whether the vault is remotely stored. diff --git a/src/Sdk/SecureFolderFS.Sdk/Models/IWidgetModel.cs b/src/Sdk/SecureFolderFS.Sdk/Models/IWidgetModel.cs index 4c779513c..2d108f825 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Models/IWidgetModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Models/IWidgetModel.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Threading.Tasks; +using SecureFolderFS.Sdk.DataModels; namespace SecureFolderFS.Sdk.Models { @@ -13,6 +14,11 @@ public interface IWidgetModel /// string WidgetId { get; } + /// + /// Gets the widget data model associated with this widget. + /// + WidgetDataModel DataModel { get; } + /// /// Gets the data saved in this widget. /// diff --git a/src/Sdk/SecureFolderFS.Sdk/Models/IWidgetsCollectionModel.cs b/src/Sdk/SecureFolderFS.Sdk/Models/IWidgetsCollectionModel.cs index 295f9d932..b1ea611cb 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Models/IWidgetsCollectionModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Models/IWidgetsCollectionModel.cs @@ -1,6 +1,7 @@ using SecureFolderFS.Shared.ComponentModel; using System.Collections.Generic; using System.Collections.Specialized; +using System.Threading.Tasks; namespace SecureFolderFS.Sdk.Models { @@ -23,6 +24,13 @@ public interface IWidgetsCollectionModel : INotifyCollectionChanged, IPersistabl /// If successful, returns true; otherwise false. bool RemoveWidget(string widgetId); + /// + /// Updates the order of widgets based on the provided list of widgets. + /// + /// The newly ordered list of widgets. + /// A that represents the asynchronous operation. + void UpdateOrder(IList orderedWidgets); + /// /// Gets all persisted widgets. /// diff --git a/src/Sdk/SecureFolderFS.Sdk/SecureFolderFS.Sdk.csproj b/src/Sdk/SecureFolderFS.Sdk/SecureFolderFS.Sdk.csproj index 768f9247f..47f26ff98 100644 --- a/src/Sdk/SecureFolderFS.Sdk/SecureFolderFS.Sdk.csproj +++ b/src/Sdk/SecureFolderFS.Sdk/SecureFolderFS.Sdk.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/IPrivacyService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/IPrivacyService.cs new file mode 100644 index 000000000..d69838d31 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/Services/IPrivacyService.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace SecureFolderFS.Sdk.Services +{ + /// + /// A service that provides privacy-related functionality. + /// + public interface IPrivacyService + { + /// + /// Clears all traces of file system usage, including caches and recent access history. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. Returns true if traces were successfully cleared. + Task ClearTracesAsync(CancellationToken cancellationToken = default); + } +} + diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/IPropertyStoreService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/IPropertyStoreService.cs index b9af8e7a9..581b8df34 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/IPropertyStoreService.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/IPropertyStoreService.cs @@ -18,6 +18,16 @@ public interface IPropertyStoreService /// IPropertyStore SecurePropertyStore { get; } + /// + /// Provides an in-memory implementation of a property store for temporary data management. + /// + /// + /// The class allows storage and retrieval of data using a key-value mechanism. + /// It operates entirely in memory, meaning all persistent data is lost when the application exits or the instance is disposed of. + /// This implementation is suitable for scenarios where lightweight, non-persistent data storage is required or for testing purposes only. + /// + IPropertyStore InMemoryPropertyStore { get; } + /// /// Gets a new for storing data in a . /// diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs index f9dd45a94..b8ac5976b 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs @@ -25,7 +25,7 @@ public interface IVaultManagerService /// The required options to set for this vault. /// A that cancels this action. /// A that represents the asynchronous operation. Value is that represents the recovery key used to decrypt the vault. - Task CreateAsync(IFolder vaultFolder, IKey passkey, VaultOptions vaultOptions, CancellationToken cancellationToken = default); + Task CreateAsync(IFolder vaultFolder, IKeyUsage passkey, VaultOptions vaultOptions, CancellationToken cancellationToken = default); /// /// Unlocks the specified using the provided . @@ -34,7 +34,7 @@ public interface IVaultManagerService /// /// A that cancels this action. /// A that represents the asynchronous operation. Value is that represents the recovery key used to decrypt the vault. - Task UnlockAsync(IFolder vaultFolder, IKey passkey, CancellationToken cancellationToken = default); + Task UnlockAsync(IFolder vaultFolder, IKeyUsage passkey, CancellationToken cancellationToken = default); /// /// Recovers the specified using the provided . @@ -57,6 +57,6 @@ public interface IVaultManagerService /// >The required options to set for this vault. /// A that cancels this action. /// A that represents the asynchronous operation. - Task ModifyAuthenticationAsync(IFolder vaultFolder, IDisposable unlockContract, IKey newPasskey, VaultOptions vaultOptions, CancellationToken cancellationToken = default); + Task ModifyAuthenticationAsync(IFolder vaultFolder, IDisposable unlockContract, IKeyUsage newPasskey, VaultOptions vaultOptions, CancellationToken cancellationToken = default); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/IVaultService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/IVaultService.cs index 9d078e3fb..3f60ae457 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/IVaultService.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/IVaultService.cs @@ -21,6 +21,11 @@ public interface IVaultService // TODO: Move some of the methods to IVaultModel? /// string ContentFolderName { get; } + /// + /// The file extension for vault shortcut files. + /// + public string ShortcutFileExtension { get; } + /// /// Gets the of type used to validate vaults. /// diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IUserSettings.cs b/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IUserSettings.cs index 7de335a52..09b635c13 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IUserSettings.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IUserSettings.cs @@ -1,5 +1,8 @@ using SecureFolderFS.Shared.ComponentModel; using System.ComponentModel; +using System.IO; +using System.Threading; +using System.Threading.Tasks; namespace SecureFolderFS.Sdk.Services.Settings { @@ -78,6 +81,30 @@ public interface IUserSettings : IPersistable, INotifyPropertyChanged /// bool DisableRecentAccess { get; set; } + /// + /// Gets or sets a value that enables or disables the Device Link listening. + /// + bool EnableDeviceLink { get; set; } + + #endregion + + #region Import/Export + + /// + /// Exports the user settings to a stream as a zip archive. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. Returns a containing the exported settings. + Task ExportAsync(CancellationToken cancellationToken = default); + + /// + /// Imports the user settings from a stream containing a zip archive. + /// + /// The data stream containing the zip archive with settings. + /// A that cancels this action. + /// A that represents the asynchronous operation. Returns true if import was successful, false otherwise. + Task ImportAsync(Stream dataStream, CancellationToken cancellationToken = default); + #endregion } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/AuthenticationViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/AuthenticationViewModel.cs index e6c7a4996..953cbe318 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/AuthenticationViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/AuthenticationViewModel.cs @@ -46,10 +46,10 @@ public override void Report(IResult? result) public abstract Task RevokeAsync(string? id, CancellationToken cancellationToken = default); /// - public abstract Task EnrollAsync(string id, byte[]? data, CancellationToken cancellationToken = default); + public abstract Task> EnrollAsync(string id, byte[]? data, CancellationToken cancellationToken = default); /// - public abstract Task AcquireAsync(string id, byte[]? data, CancellationToken cancellationToken = default); + public abstract Task> AcquireAsync(string id, byte[]? data, CancellationToken cancellationToken = default); [RelayCommand] protected abstract Task ProvideCredentialsAsync(CancellationToken cancellationToken); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs index a7956f920..c3e73c616 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs @@ -1,4 +1,9 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using OwlCore.Storage; using SecureFolderFS.Sdk.AppModels; @@ -14,12 +19,6 @@ using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; -using System; -using System.ComponentModel; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Input; namespace SecureFolderFS.Sdk.ViewModels.Controls { diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/TextPreviewerViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/TextPreviewerViewModel.cs index c95cc13a5..20082491b 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/TextPreviewerViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/TextPreviewerViewModel.cs @@ -10,7 +10,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Previewers { [Bindable(true)] - public sealed partial class TextPreviewerViewModel : FilePreviewerViewModel, IPersistable + public sealed partial class TextPreviewerViewModel : FilePreviewerViewModel, IChangeTracker, IPersistable { private string? _persistedText; @@ -49,6 +49,7 @@ public async Task SaveAsync(CancellationToken cancellationToken = default) if (Text is null) return; + //await Task.Delay(5000, cancellationToken); await Inner.WriteTextAsync(Text, cancellationToken); _persistedText = Text; WasModified = false; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/RegisterViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/RegisterViewModel.cs index bfff66c63..d9c881287 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/RegisterViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/RegisterViewModel.cs @@ -63,7 +63,7 @@ public async Task RevokeCredentialsAsync(CancellationToken cancellationToken) private async Task ConfirmCredentialsAsync() { // In case the authentication was not reported, try to extract it manually, if possible - if (!_credentialsAdded && CurrentViewModel is IWrapper keyWrapper) + if (!_credentialsAdded && CurrentViewModel is IWrapper keyWrapper) { Credentials.SetOrAdd(_authenticationStage == AuthenticationStage.FirstStageOnly ? 0 : 1, keyWrapper.Inner); _credentialsAdded = true; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs index 9875793ed..41ee9ecbd 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs @@ -84,10 +84,10 @@ protected virtual async Task MoveAsync(CancellationToken cancellationToken) try { - // Disable selection, if called with selected items + // Disable selection if called with selected items BrowserViewModel.IsSelecting = false; - using var cts = transferViewModel.GetCancellation(); + using var cts = transferViewModel.GetCancellation(cancellationToken); var destination = await transferViewModel.PickFolderAsync(new TransferOptions(TransferType.Move), false, cts.Token); if (destination is not IModifiableFolder destinationFolder) return; @@ -147,7 +147,7 @@ protected virtual async Task CopyAsync(CancellationToken cancellationToken) // Disable selection, if called with selected items BrowserViewModel.IsSelecting = false; - using var cts = transferViewModel.GetCancellation(); + using var cts = transferViewModel.GetCancellation(cancellationToken); var destination = await transferViewModel.PickFolderAsync(new TransferOptions(TransferType.Copy), false, cts.Token); if (destination is not IModifiableFolder modifiableDestination) return; @@ -332,7 +332,7 @@ protected virtual async Task ExportAsync(CancellationToken cancellationToken) return; transferViewModel.TransferType = TransferType.Move; - using var cts = transferViewModel.GetCancellation(); + using var cts = transferViewModel.GetCancellation(cancellationToken); await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, reporter, token) => { // Copy and delete diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs index 78165b3d9..da81f6513 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs @@ -4,11 +4,13 @@ using System.Threading.Tasks; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Sdk.ViewModels.Views.Vault; using SecureFolderFS.Shared; +using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Enums; using SecureFolderFS.Shared.Helpers; @@ -67,6 +69,38 @@ protected override async Task OpenAsync(CancellationToken cancellationToken) using var viewModel = new PreviewerOverlayViewModel(this, ParentFolder); await viewModel.InitAsync(cancellationToken); await OverlayService.ShowAsync(viewModel); + + if (BrowserViewModel.Options.IsReadOnly) + return; + + if (viewModel.PreviewerViewModel is IChangeTracker { WasModified: true } and IPersistable persistable) + { + var messageOverlay = new MessageOverlayViewModel() + { + Title = "UnsavedChanges".ToLocalized(), + Message = "UnsavedChangesDescription".ToLocalized(), + PrimaryText = "Save".ToLocalized(), + SecondaryText = "Cancel".ToLocalized() + }; + + await Task.Delay(700); + var result = await OverlayService.ShowAsync(messageOverlay); + if (!result.Positive()) + return; + + if (BrowserViewModel.TransferViewModel is not { } transferViewModel) + { + await persistable.SaveAsync(cancellationToken); + return; + } + + transferViewModel.TransferType = TransferType.Save; + using var saveCancellation = transferViewModel.GetCancellation(); + await transferViewModel.PerformOperationAsync(async ct => + { + await persistable.SaveAsync(ct); + }, saveCancellation.Token); + } } } } \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs index ba4c136d1..5b7253c92 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs @@ -64,7 +64,7 @@ public async Task ListContentsAsync(CancellationToken cancellationToken = defaul })), Items); // Apply adaptive layout - if (SettingsService.UserSettings.IsAdaptiveLayoutEnabled) + if (SettingsService.UserSettings.IsAdaptiveLayoutEnabled && BrowserViewModel.TransferViewModel is { IsPickingFolder: false }) ApplyAdaptiveLayout(); } @@ -72,7 +72,7 @@ public async Task ListContentsAsync(CancellationToken cancellationToken = defaul public virtual void OnAppearing() { // Apply adaptive layout when back to a folder - if (!Items.IsEmpty() && SettingsService.UserSettings.IsAdaptiveLayoutEnabled) + if (!Items.IsEmpty() && SettingsService.UserSettings.IsAdaptiveLayoutEnabled && BrowserViewModel.TransferViewModel is { IsPickingFolder: false }) ApplyAdaptiveLayout(); } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/LayoutsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/LayoutsViewModel.cs index 713d138ac..f791e8174 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/LayoutsViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/LayoutsViewModel.cs @@ -1,6 +1,8 @@ using System.Collections.ObjectModel; using System.ComponentModel; +using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using SecureFolderFS.Sdk.AppModels.Sorters; using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Extensions; @@ -73,15 +75,32 @@ partial void OnCurrentViewOptionChanged(PickerOptionViewModel? value) break; } - SetLayoutSizeOption(CurrentSizeOption); + UpdateLayoutSizeOption(CurrentSizeOption); } partial void OnCurrentSizeOptionChanged(PickerOptionViewModel? value) { - SetLayoutSizeOption(value); + UpdateLayoutSizeOption(value); } - private bool SetLayoutSizeOption(PickerOptionViewModel? value) + [RelayCommand] + private void SetLayoutSizeOption(object? value) + { + CurrentSizeOption = value switch + { + int index => SizeOptions.ElementAtOrDefault(index), + string strValue => strValue.ToLowerInvariant() switch + { + "small" => SizeOptions[0], + "medium" => SizeOptions[1], + "large" => SizeOptions[2], + _ => CurrentSizeOption + }, + _ => CurrentSizeOption + }; + } + + private bool UpdateLayoutSizeOption(PickerOptionViewModel? value) { switch (CurrentViewOption?.Id) { diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs index 4da363ab5..347de3d3d 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs @@ -62,10 +62,12 @@ static string GetItemsCount(TotalProgress totalProgress) } } - public CancellationTokenSource GetCancellation() + public CancellationTokenSource GetCancellation(CancellationToken? linkToken = null) { _cts?.Dispose(); - _cts = new CancellationTokenSource(); + _cts = linkToken is not null + ? CancellationTokenSource.CreateLinkedTokenSource(linkToken.Value) + : new CancellationTokenSource(); CanCancel = true; return _cts; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultControlsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultControlsViewModel.cs index 7ad498b85..b8c98ddd2 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultControlsViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultControlsViewModel.cs @@ -1,9 +1,4 @@ -using System; -using System.ComponentModel; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using SecureFolderFS.Sdk.Attributes; @@ -14,7 +9,10 @@ using SecureFolderFS.Sdk.ViewModels.Views.Vault; using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Shared.Extensions; +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; namespace SecureFolderFS.Sdk.ViewModels.Controls { @@ -94,7 +92,7 @@ private async Task OpenPropertiesAsync(CancellationToken cancellationToken) if (_propertiesViewModel is null) { _propertiesViewModel = new(_unlockedVaultViewModel, _dashboardNavigator, _vaultNavigation); - await _propertiesViewModel.InitAsync(cancellationToken); + _ = _propertiesViewModel.InitAsync(cancellationToken); } await _dashboardNavigator.NavigateAsync(_propertiesViewModel); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs index d17a1d84c..a0df7d327 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs @@ -1,34 +1,37 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; +using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.DataModels; +using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Messages; using SecureFolderFS.Sdk.Models; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared; +using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Helpers; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage; +using SecureFolderFS.Storage.Extensions; using System; +using System.Collections.Generic; using System.ComponentModel; using System.Threading; using System.Threading.Tasks; -using OwlCore.Storage; -using SecureFolderFS.Sdk.Extensions; -using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Shared.Helpers; -using SecureFolderFS.Storage.Extensions; -using SecureFolderFS.Sdk.ViewModels.Views.Overlays; -using SecureFolderFS.Storage; namespace SecureFolderFS.Sdk.ViewModels.Controls.VaultList { - [Inject, Inject, Inject] + [Inject, Inject, Inject, Inject] [Bindable(true)] - public sealed partial class VaultListItemViewModel : ObservableObject, IAsyncInitialize, IRecipient, IRecipient + public sealed partial class VaultListItemViewModel : ObservableObject, IAsyncInitialize { private readonly IVaultCollectionModel _vaultCollectionModel; + [ObservableProperty] private bool _CanCreateShortcut; [ObservableProperty] private bool _IsRenaming; - [ObservableProperty] private bool _IsUnlocked; [ObservableProperty] private bool _CanMoveDown; [ObservableProperty] private bool _CanMoveUp; [ObservableProperty] private bool _CanMove; @@ -39,11 +42,10 @@ public VaultListItemViewModel(VaultViewModel vaultViewModel, IVaultCollectionMod { ServiceProvider = DI.Default; VaultViewModel = vaultViewModel; + CanCreateShortcut = OperatingSystem.IsWindows(); _vaultCollectionModel = vaultCollectionModel; UpdateCanMove(); - WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); } /// @@ -52,23 +54,6 @@ public async Task InitAsync(CancellationToken cancellationToken = default) await UpdateIconAsync(cancellationToken); } - /// - public void Receive(VaultUnlockedMessage message) - { - if (VaultViewModel.VaultModel.Equals(message.VaultModel)) - { - // Prevent from removing vault if it is unlocked - IsUnlocked = true; - } - } - - /// - public void Receive(VaultLockedMessage message) - { - if (VaultViewModel.VaultModel.Equals(message.VaultModel)) - IsUnlocked = false; - } - [RelayCommand] private void RequestLock() { @@ -166,6 +151,24 @@ private async Task RevealFolderAsync(CancellationToken cancellationToken) await FileExplorerService.TryOpenInFileExplorerAsync(vaultFolder, cancellationToken); } + [RelayCommand] + private async Task CreateShortcutAsync(CancellationToken cancellationToken) + { + if (VaultViewModel.VaultModel.VaultFolder is not { } vaultFolder) + return; + + var shortcutData = new VaultShortcutDataModel(VaultViewModel.VaultModel.DataModel.PersistableId, VaultViewModel.Title); + var suggestedName = $"{VaultViewModel.Title}{VaultService.ShortcutFileExtension}"; + await using var dataStream = await StreamSerializer.Instance.SerializeAsync(shortcutData, cancellationToken); + + var filter = new Dictionary + { + { "SecureFolderFS Vault", VaultService.ShortcutFileExtension } + }; + + await FileExplorerService.SaveFileAsync(suggestedName, dataStream, filter, cancellationToken); + } + private async Task UpdateIconAsync(CancellationToken cancellationToken) { if (VaultViewModel.VaultModel.VaultFolder is not { } vaultFolder) diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/BaseWidgetViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/BaseWidgetViewModel.cs index 34c4d0ce4..5705bb5fd 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/BaseWidgetViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/BaseWidgetViewModel.cs @@ -12,8 +12,10 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Widgets /// A base class for all widget view models. /// [Bindable(true)] - public abstract class BaseWidgetViewModel : ObservableObject, IAsyncInitialize, IDisposable + public abstract partial class BaseWidgetViewModel : ObservableObject, IAsyncInitialize, IDisposable, IViewable { + [ObservableProperty] private string? _Title; + /// /// Gets the widget model interface used for manipulating widget data. /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/AggregatedDataWidgetViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/AggregatedDataWidgetViewModel.cs index 70572cce4..cd7088cfb 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/AggregatedDataWidgetViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/AggregatedDataWidgetViewModel.cs @@ -1,11 +1,12 @@ -using ByteSizeLib; -using CommunityToolkit.Mvvm.ComponentModel; -using SecureFolderFS.Sdk.Models; -using SecureFolderFS.Storage.VirtualFileSystem; using System; using System.ComponentModel; using System.Threading; using System.Threading.Tasks; +using ByteSizeLib; +using CommunityToolkit.Mvvm.ComponentModel; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Models; +using SecureFolderFS.Storage.VirtualFileSystem; namespace SecureFolderFS.Sdk.ViewModels.Controls.Widgets.Data { @@ -14,6 +15,8 @@ public sealed partial class AggregatedDataWidgetViewModel : BaseWidgetViewModel { private readonly IFileSystemStatistics _fileSystemStatistics; private readonly PeriodicTimer _periodicTimer; + private IDisposable? _bytesReadSubscription; + private IDisposable? _bytesWrittenSubscription; private ulong _pendingBytesRead; private ulong _pendingBytesWritten; private ByteSize _bytesRead; @@ -27,6 +30,7 @@ public AggregatedDataWidgetViewModel(UnlockedVaultViewModel unlockedVaultViewMod { _fileSystemStatistics = unlockedVaultViewModel.StorageRoot.Options.FileSystemStatistics; _periodicTimer = new(TimeSpan.FromMilliseconds(Constants.Widgets.Graphs.GRAPH_UPDATE_INTERVAL_MS)); + Title = "AggregatedDataWidget".ToLocalized(); } /// @@ -37,16 +41,21 @@ public override Task InitAsync(CancellationToken cancellationToken = default) TotalRead = "0B"; TotalWrite = "0B"; - _fileSystemStatistics.BytesRead = new Progress(x => - { - if (x > 0) - _pendingBytesRead += (ulong)x; - }); - _fileSystemStatistics.BytesWritten = new Progress(x => + // Subscribe to statistics if it supports subscription + if (_fileSystemStatistics is IFileSystemStatisticsSubscriber subscriber) { - if (x > 0) - _pendingBytesWritten += (ulong)x; - }); + _bytesReadSubscription = subscriber.SubscribeToBytesRead(new Progress(x => + { + if (x > 0) + _pendingBytesRead += (ulong)x; + })); + + _bytesWrittenSubscription = subscriber.SubscribeToBytesWritten(new Progress(x => + { + if (x > 0) + _pendingBytesWritten += (ulong)x; + })); + } // We don't want to await it, since it's an async based timer _ = InitializeBlockingTimer(cancellationToken); @@ -77,8 +86,8 @@ private async Task InitializeBlockingTimer(CancellationToken cancellationToken) /// public override void Dispose() { - _fileSystemStatistics.BytesRead = null; - _fileSystemStatistics.BytesWritten = null; + _bytesReadSubscription?.Dispose(); + _bytesWrittenSubscription?.Dispose(); _periodicTimer.Dispose(); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphsWidgetViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphsWidgetViewModel.cs index 9665dd703..a56269d12 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphsWidgetViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Data/GraphsWidgetViewModel.cs @@ -1,14 +1,15 @@ -using ByteSizeLib; -using CommunityToolkit.Mvvm.ComponentModel; -using SecureFolderFS.Sdk.Models; -using SecureFolderFS.Shared.Extensions; -using SecureFolderFS.Storage.VirtualFileSystem; -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Threading; using System.Threading.Tasks; +using ByteSizeLib; +using CommunityToolkit.Mvvm.ComponentModel; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Models; +using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Storage.VirtualFileSystem; namespace SecureFolderFS.Sdk.ViewModels.Controls.Widgets.Data { @@ -19,6 +20,8 @@ public sealed partial class GraphsWidgetViewModel : BaseWidgetViewModel private readonly PeriodicTimer _periodicTimer; private readonly List _readRates; private readonly List _writeRates; + private IDisposable? _bytesReadSubscription; + private IDisposable? _bytesWrittenSubscription; private long _currentReadAmount; private long _currentWriteAmount; private int _updateTimeCount; @@ -31,6 +34,7 @@ public GraphsWidgetViewModel(UnlockedVaultViewModel unlockedVaultViewModel, IWid : base(widgetModel) { IsActive = true; + Title = "GraphsWidget".ToLocalized(); ReadGraphViewModel = new(); WriteGraphViewModel = new(); _fileSystemStatistics = unlockedVaultViewModel.StorageRoot.Options.FileSystemStatistics; @@ -46,8 +50,12 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau await ReadGraphViewModel.InitAsync(cancellationToken); await WriteGraphViewModel.InitAsync(cancellationToken); - _fileSystemStatistics.BytesRead = new Progress(x => _currentReadAmount += x); - _fileSystemStatistics.BytesWritten = new Progress(x => _currentWriteAmount += x); + // Subscribe to statistics if it supports subscription + if (_fileSystemStatistics is IFileSystemStatisticsSubscriber subscriber) + { + _bytesReadSubscription = subscriber.SubscribeToBytesRead(new Progress(x => _currentReadAmount += x)); + _bytesWrittenSubscription = subscriber.SubscribeToBytesWritten(new Progress(x => _currentWriteAmount += x)); + } // We don't want to await it, since it's an async based timer _ = InitializeBlockingTimer(cancellationToken); @@ -100,8 +108,8 @@ private void CalculateStatistics() /// public override void Dispose() { - _fileSystemStatistics.BytesRead = null; - _fileSystemStatistics.BytesWritten = null; + _bytesReadSubscription?.Dispose(); + _bytesWrittenSubscription?.Dispose(); _periodicTimer.Dispose(); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Health/HealthWidgetViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Health/HealthWidgetViewModel.cs index b62b1fe6d..d9d0e2b58 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Health/HealthWidgetViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/Health/HealthWidgetViewModel.cs @@ -33,6 +33,7 @@ public HealthWidgetViewModel(UnlockedVaultViewModel unlockedVaultViewModel, INav ServiceProvider = DI.Default; _context = SynchronizationContext.Current; _dashboardNavigator = dashboardNavigator; + Title = "HealthWidget".ToLocalized(); HealthViewModel = new(unlockedVaultViewModel); HealthReportViewModel = new(unlockedVaultViewModel, HealthViewModel); LastCheckedText = string.Format("LastChecked".ToLocalized(), "Unspecified"); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/WidgetsListViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/WidgetsListViewModel.cs index 2fca1d28c..84c8a9a39 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/WidgetsListViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Widgets/WidgetsListViewModel.cs @@ -1,14 +1,17 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using SecureFolderFS.Sdk.Helpers; using SecureFolderFS.Sdk.Models; using SecureFolderFS.Sdk.ViewModels.Controls.Widgets.Data; using SecureFolderFS.Sdk.ViewModels.Controls.Widgets.Health; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; -using System; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Sdk.ViewModels.Controls.Widgets { @@ -18,6 +21,7 @@ public sealed class WidgetsListViewModel : ObservableObject, IAsyncInitialize, I private readonly INavigator _dashboardNavigator; private readonly UnlockedVaultViewModel _unlockedVaultViewModel; private readonly SynchronizationContext? _synchronizationContext; + private readonly CollectionReorderHelper _reorderHelper; public IWidgetsCollectionModel WidgetsCollectionModel { get; } @@ -28,8 +32,13 @@ public WidgetsListViewModel(UnlockedVaultViewModel unlockedVaultViewModel, INavi _dashboardNavigator = dashboardNavigator; _unlockedVaultViewModel = unlockedVaultViewModel; _synchronizationContext = SynchronizationContext.Current; + _reorderHelper = new(); WidgetsCollectionModel = widgetsCollectionModel; Widgets = new(); + + // Subscribe to collection changes to persist reordering + Widgets.CollectionChanged += Widgets_CollectionChanged; + _reorderHelper.Reordered += ReorderHelper_Reordered; } /// @@ -54,27 +63,69 @@ await _synchronizationContext.PostOrExecuteAsync(async state => }); } - private BaseWidgetViewModel? GetWidgetForModel(IWidgetModel widgetModel) + private void Widgets_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - switch (widgetModel.WidgetId) + switch (e.Action) { - case Constants.Widgets.HEALTH_WIDGET_ID: - return new HealthWidgetViewModel(_unlockedVaultViewModel, _dashboardNavigator, widgetModel); + case NotifyCollectionChangedAction.Add: + { + if (e.NewItems?.Cast().FirstOrDefault() is { } widget) + _reorderHelper.RegisterAdd(widget); + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + if (e.OldItems?.Cast().FirstOrDefault() is { } widget) + _reorderHelper.RegisterRemove(widget); + + break; + } + } + } + + private async void ReorderHelper_Reordered(object? sender, BaseWidgetViewModel e) + { + await PersistWidgetOrderAsync(); + } - case Constants.Widgets.GRAPHS_WIDGET_ID: - return new GraphsWidgetViewModel(_unlockedVaultViewModel, widgetModel); + private async Task PersistWidgetOrderAsync() + { + try + { + // Get the current order from the ObservableCollection + var orderedWidgets = Widgets.Select(w => w.WidgetModel).ToArray(); - case Constants.Widgets.AGGREGATED_DATA_WIDGET_ID: - return new AggregatedDataWidgetViewModel(_unlockedVaultViewModel, widgetModel); + // Update the underlying collection model + WidgetsCollectionModel.UpdateOrder(orderedWidgets); - default: - return null; + // Persist the changes + await WidgetsCollectionModel.SaveAsync(); } + catch (Exception) + { + } + } + + private BaseWidgetViewModel? GetWidgetForModel(IWidgetModel widgetModel) + { + return widgetModel.WidgetId switch + { + Constants.Widgets.HEALTH_WIDGET_ID => new HealthWidgetViewModel(_unlockedVaultViewModel, _dashboardNavigator, widgetModel), + Constants.Widgets.GRAPHS_WIDGET_ID => new GraphsWidgetViewModel(_unlockedVaultViewModel, widgetModel), + Constants.Widgets.AGGREGATED_DATA_WIDGET_ID => new AggregatedDataWidgetViewModel(_unlockedVaultViewModel, widgetModel), + _ => null + }; } /// public void Dispose() { + _reorderHelper.Reordered -= ReorderHelper_Reordered; + _reorderHelper.Dispose(); + + Widgets.CollectionChanged -= Widgets_CollectionChanged; Widgets.DisposeAll(); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/VaultViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/VaultViewModel.cs index 607b13acc..d246d30ec 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/VaultViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/VaultViewModel.cs @@ -23,10 +23,13 @@ namespace SecureFolderFS.Sdk.ViewModels { [Inject, Inject, Inject] [Bindable(true)] - public sealed partial class VaultViewModel : ObservableObject, IViewable, IDisposable + public sealed partial class VaultViewModel : ObservableObject, IViewable, IDisposable, IRecipient { + private UnlockedVaultViewModel? _unlockedVaultViewModel; + [ObservableProperty] private string? _Title; [ObservableProperty] private bool _CanRename; + [ObservableProperty] private bool _IsUnlocked; [ObservableProperty] private DateTime? _LastAccessDate; /// @@ -42,6 +45,18 @@ public VaultViewModel(IVaultModel vaultModel) CanRename = !vaultModel.IsRemote || vaultModel.VaultFolder is not null; LastAccessDate = vaultModel.DataModel.LastAccessDate; vaultModel.StateChanged += VaultModel_StateChanged; + + WeakReferenceMessenger.Default.Register(this); + } + + /// + public void Receive(VaultLockedMessage message) + { + if (VaultModel.Equals(message.VaultModel)) + { + _unlockedVaultViewModel = null; + IsUnlocked = false; + } } [RelayCommand] @@ -76,6 +91,9 @@ public async Task SetLastAccessDateAsync(DateTime? newDate, CancellationToken ca public async Task UnlockAsync(IDisposable unlockContract, bool isReadOnly) { + if (IsUnlocked) + throw new InvalidOperationException("The vault is already unlocked."); + if (VaultModel.VaultFolder is not { } vaultFolder) throw new InvalidOperationException("The vault folder is not set."); @@ -102,9 +120,16 @@ public async Task UnlockAsync(IDisposable unlockContract await SetLastAccessDateAsync(DateTime.Now, CancellationToken.None); // Notify that the vault has been unlocked + IsUnlocked = true; + _unlockedVaultViewModel = new(vaultFolder, storageRoot, this); WeakReferenceMessenger.Default.Send(new VaultUnlockedMessage(VaultModel)); - return new(vaultFolder, storageRoot, this); + return _unlockedVaultViewModel; + } + + public UnlockedVaultViewModel GetUnlockedViewModel() + { + return _unlockedVaultViewModel ?? throw new UnauthorizedAccessException("The vault is not unlocked."); } private void VaultModel_StateChanged(object? sender, EventArgs e) @@ -118,6 +143,7 @@ private void VaultModel_StateChanged(object? sender, EventArgs e) /// public void Dispose() { + WeakReferenceMessenger.Default.UnregisterAll(this); VaultModel.StateChanged -= VaultModel_StateChanged; VaultModel.Dispose(); } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs index fc82f8334..f781afbb7 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs @@ -25,7 +25,7 @@ public sealed partial class CredentialsConfirmationViewModel : ObservableObject, { private readonly IFolder _vaultFolder; private readonly AuthenticationStage _authenticationStage; - private readonly TaskCompletionSource _credentialsTcs; + private readonly TaskCompletionSource _credentialsTcs; [ObservableProperty] private bool _IsRemoving; [ObservableProperty] private bool _IsComplementing; @@ -96,7 +96,7 @@ private async Task RemoveAsync(CancellationToken cancellationToken) await ChangeCredentialsAsync(key, configuredOptions, authenticationMethod, cancellationToken); } - private async Task ChangeCredentialsAsync(IKey key, VaultOptions configuredOptions, AuthenticationMethod unlockProcedure, CancellationToken cancellationToken) + private async Task ChangeCredentialsAsync(IKeyUsage key, VaultOptions configuredOptions, AuthenticationMethod unlockProcedure, CancellationToken cancellationToken) { // Modify the current unlock procedure await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, UnlockContract, key, configuredOptions with diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs index 114f38fa3..ea05cd964 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs @@ -24,7 +24,7 @@ public sealed partial class CredentialsResetViewModel : ObservableObject, IAsync { private readonly IFolder _vaultFolder; private readonly IDisposable _unlockContract; - private readonly TaskCompletionSource _credentialsTcs; + private readonly TaskCompletionSource _credentialsTcs; [ObservableProperty] private RegisterViewModel _RegisterViewModel; [ObservableProperty] private ObservableCollection _AuthenticationOptions = new(); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/EmptyHostViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/EmptyHostViewModel.cs index d96077a27..cc3756460 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/EmptyHostViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/EmptyHostViewModel.cs @@ -4,6 +4,7 @@ using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Models; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Controls.VaultList; using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; @@ -22,16 +23,19 @@ public sealed partial class EmptyHostViewModel : ObservableObject, IViewDesignat [ObservableProperty] private string? _Title; - public EmptyHostViewModel(INavigationService rootNavigationService, IVaultCollectionModel vaultCollectionModel) + public VaultListViewModel VaultListViewModel { get; } + + public EmptyHostViewModel(VaultListViewModel vaultListViewModel, INavigationService rootNavigationService, IVaultCollectionModel vaultCollectionModel) { ServiceProvider = DI.Default; + VaultListViewModel = vaultListViewModel; _rootNavigationService = rootNavigationService; _vaultCollectionModel = vaultCollectionModel; } private async void VaultCollectionModel_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { - await _rootNavigationService.TryNavigateAsync(() => new MainHostViewModel(_vaultCollectionModel), false); + await _rootNavigationService.TryNavigateAsync(() => new MainHostViewModel(VaultListViewModel, _vaultCollectionModel), false); } [RelayCommand] diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/MainHostViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/MainHostViewModel.cs index cab106c61..476e5cafe 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/MainHostViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Host/MainHostViewModel.cs @@ -28,13 +28,13 @@ public sealed partial class MainHostViewModel : BaseDesignationViewModel, IAsync public VaultListViewModel VaultListViewModel { get; } - public MainHostViewModel(IVaultCollectionModel vaultCollectionModel) + public MainHostViewModel(VaultListViewModel vaultListViewModel, IVaultCollectionModel vaultCollectionModel) { ServiceProvider = DI.Default; _vaultCollectionModel = vaultCollectionModel; _systemMonitorModel = new SystemMonitorModel(vaultCollectionModel); Title = "MyVaults".ToLocalized(); - VaultListViewModel = new(vaultCollectionModel); + VaultListViewModel = vaultListViewModel; _vaultCollectionModel.CollectionChanged += VaultCollectionModel_CollectionChanged; } @@ -52,6 +52,15 @@ private async Task OpenSettingsAsync() await SettingsService.TrySaveAsync(); } + [RelayCommand] + private async Task OpenVaultCredentialsAsync() + { + using var viewModel = new DeviceLinkCredentialsOverlayViewModel(); + _ = viewModel.InitAsync(); + + await OverlayService.ShowAsync(viewModel); + } + private void VaultCollectionModel_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkCredentialsOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkCredentialsOverlayViewModel.cs new file mode 100644 index 000000000..14c01adf2 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkCredentialsOverlayViewModel.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.PhoneLink.Models; +using SecureFolderFS.Sdk.PhoneLink.Services; +using SecureFolderFS.Sdk.PhoneLink.ViewModels; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays +{ + [Bindable(true)] + [Inject, Inject, Inject] + public sealed partial class DeviceLinkCredentialsOverlayViewModel : OverlayViewModel, IAsyncInitialize, IDisposable + { + private DeviceLinkService? _deviceLinkService; + private readonly CredentialsStoreModel _credentialsStoreModel; + private readonly SynchronizationContext? _synchronizationContext; + private bool _isInitialized; + + [ObservableProperty] private bool _IsAwaitingPairing; + [ObservableProperty] private string? _VerificationCode; + [ObservableProperty] private string? _PendingDesktopName; + [ObservableProperty] private string? _NewCredentialName; + [ObservableProperty] private ObservableCollection _Credentials; + + public bool EnableDeviceLink + { + get => SettingsService.UserSettings.EnableDeviceLink; + set + { + if (SettingsService.UserSettings.EnableDeviceLink == value) + return; + + SettingsService.UserSettings.EnableDeviceLink = value; + OnPropertyChanged(); + OnEnablePhoneLinkChanged(value); + } + } + + public DeviceLinkCredentialsOverlayViewModel() + { + ServiceProvider = DI.Default; + Credentials = new(); + _synchronizationContext = SynchronizationContext.Current; + _credentialsStoreModel = new(PropertyStoreService.SecurePropertyStore, StreamSerializer.Instance); + } + + /// + public async Task InitAsync(CancellationToken cancellationToken = default) + { + // Load saved credentials from the property store + await _credentialsStoreModel.InitAsync(cancellationToken); + + // Populate the observable collection with loaded credentials + Credentials.Clear(); + foreach (var credential in _credentialsStoreModel.Credentials) + Credentials.Add(credential); + + _isInitialized = true; + + // If DeviceLink is enabled, start listening + if (EnableDeviceLink) + await StartListeningAsync(); + } + + [RelayCommand] + private void AcceptPairing() + { + _deviceLinkService?.ConfirmPairingRequest(true); + IsAwaitingPairing = false; + } + + [RelayCommand] + private void RejectPairing() + { + _deviceLinkService?.ConfirmPairingRequest(false); + IsAwaitingPairing = false; + } + + [RelayCommand] + private async Task DeleteCredentialAsync(CredentialViewModel credential) + { + await _credentialsStoreModel.DeleteCredentialAsync(credential.Id); + Credentials.Remove(credential); + } + + private void OnEnablePhoneLinkChanged(bool newValue) + { + // Only start/stop listener if already initialized (prevents double-start during init) + if (_isInitialized) + { + if (newValue) + _ = StartListeningAsync(); + else + StopListening(); + } + + _ = SettingsService.UserSettings.TrySaveAsync(); + } + + private async Task StartListeningAsync() + { + _deviceLinkService = new DeviceLinkService( + Environment.MachineName, + "SecureFolderFS Phone", + _credentialsStoreModel + ); + + _deviceLinkService.PairingRequested += (_, info) => + { + IsAwaitingPairing = true; + VerificationCode = info.VerificationCode; + PendingDesktopName = info.DesktopName; + }; + _deviceLinkService.AuthenticationRequested += DeviceLink_AuthenticationRequested; + _deviceLinkService.VerificationCodeReady += (_, code) => VerificationCode = code; + _deviceLinkService.EnrollmentCompleted += (_, credential) => Credentials.Add(credential); + _deviceLinkService.Disconnected += (_, _) => IsAwaitingPairing = false; + + await _deviceLinkService.StartListeningAsync(); + } + + private async void DeviceLink_AuthenticationRequested(object? sender, AuthenticationRequestModel e) + { + if (sender is not DeviceLinkService deviceLinkService) + return; + + await _synchronizationContext.PostOrExecuteAsync(async _ => + { + var overlayViewModel = new DeviceLinkRequestOverlayViewModel(deviceLinkService, e); + await OverlayService.ShowAsync(overlayViewModel); + }); + } + + private void StopListening() + { + _deviceLinkService?.StopListening(); + _deviceLinkService?.Dispose(); + _deviceLinkService = null; + IsAwaitingPairing = false; + } + + /// + public void Dispose() + { + StopListening(); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkRequestOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkRequestOverlayViewModel.cs new file mode 100644 index 000000000..430d5f115 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkRequestOverlayViewModel.cs @@ -0,0 +1,43 @@ +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.PhoneLink.Models; +using SecureFolderFS.Sdk.PhoneLink.Services; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays +{ + [Bindable(true)] + public sealed partial class DeviceLinkRequestOverlayViewModel : OverlayViewModel, IStagingView + { + private readonly DeviceLinkService _deviceLinkService; + + [ObservableProperty] private string? _RemoteDeviceName; + [ObservableProperty] private string? _CredentialName; + + public DeviceLinkRequestOverlayViewModel(DeviceLinkService deviceLinkService, AuthenticationRequestModel model) + { + _deviceLinkService = deviceLinkService; + RemoteDeviceName = model.DesktopName; + CredentialName = "CredentialUsed".ToLocalized(model.CredentialName); + } + + [RelayCommand] + public Task TryContinueAsync(CancellationToken cancellationToken) + { + _deviceLinkService.ConfirmAuthentication(true); + return Task.FromResult(Result.Success); + } + + [RelayCommand] + public Task TryCancelAsync(CancellationToken cancellationToken) + { + _deviceLinkService.ConfirmAuthentication(false); + return Task.FromResult(Result.Success); + } + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs index 28250ead5..dbfce4c7b 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs @@ -14,21 +14,33 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays [Bindable(true)] public sealed partial class IntroductionOverlayViewModel : OverlayViewModel { - private readonly int _maxAmount; - [ObservableProperty] private int _CurrentIndex; [ObservableProperty] private string? _CurrentStep; + public int SlidesCount { get; set; } + public TaskCompletionSource TaskCompletion { get; } - public IntroductionOverlayViewModel(int maxAmount) + public IntroductionOverlayViewModel(int slidesCount = -1) { ServiceProvider = DI.Default; - _maxAmount = maxAmount; - CurrentStep = $"1/{maxAmount}"; + SlidesCount = slidesCount; + CurrentStep = $"1/{slidesCount}"; TaskCompletion = new(); } + public void Next() + { + if (CurrentIndex < SlidesCount - 1) + CurrentIndex++; + } + + public void Previous() + { + if (CurrentIndex > 0) + CurrentIndex--; + } + [RelayCommand] private async Task OpenSettingsAsync() { @@ -38,7 +50,7 @@ private async Task OpenSettingsAsync() partial void OnCurrentIndexChanged(int value) { - CurrentStep = $"{CurrentIndex+1}/{_maxAmount}"; + CurrentStep = $"{CurrentIndex+1}/{SlidesCount}"; } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/MigrationOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/MigrationOverlayViewModel.cs index 5949c26be..14940108c 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/MigrationOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/MigrationOverlayViewModel.cs @@ -62,7 +62,7 @@ public void Report(IResult value) } [RelayCommand] - private async Task AuthenticateMigrationAsync(IKey? credentials, CancellationToken cancellationToken) + private async Task AuthenticateMigrationAsync(IKeyBytes? credentials, CancellationToken cancellationToken) { if (_vaultMigrator is null || credentials is null) return; @@ -96,7 +96,7 @@ private async Task AuthenticateRecoveryAsync(RecoveryOverlayViewModel? overlayVi // Check if configuring a new password was requested if (!string.IsNullOrEmpty(overlayViewModel.OptionalNewPassword)) { - if (_vaultMigrator is not IProgress credentialsReporter) + if (_vaultMigrator is not IProgress credentialsReporter) return; using var password = new DisposablePassword(overlayViewModel.OptionalNewPassword); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs index 25219c9f3..1ad629323 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs @@ -9,6 +9,8 @@ using System.Threading; using System.Threading.Tasks; using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays { @@ -32,7 +34,7 @@ public RecoveryOverlayViewModel(IFolder vaultFolder) _vaultFolder = vaultFolder; } - public async Task RecoverAsync(CancellationToken cancellationToken) + public async Task RecoverAsync(CancellationToken cancellationToken) { try { @@ -40,13 +42,13 @@ public async Task RecoverAsync(CancellationToken cancellationToken) UnlockContract?.Dispose(); UnlockContract = unlockContract; - return true; + return Result.Success; } - catch (Exception) + catch (Exception ex) { // TODO: Localize ErrorMessage = "The provided recovery key is invalid."; - return false; + return new MessageResult(ex, ErrorMessage); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs index bb9573156..8fc2c8c60 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs @@ -16,6 +16,7 @@ using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; @@ -58,7 +59,7 @@ public async Task InitAsync(CancellationToken cancellationToken = default) rootFolder = await childFolder.GetRootAsync(cancellationToken) ?? childFolder; // Get and populate available size options - var deviceFreeSpace = await SystemService.GetAvailableFreeSpaceAsync(rootFolder, cancellationToken); + var deviceFreeSpace = await SafetyHelpers.NoFailureAsync(async () => await SystemService.GetAvailableFreeSpaceAsync(rootFolder, cancellationToken)); var sizeOptions = RecycleBinHelpers.GetSizeOptions(deviceFreeSpace); SizeOptions.AddMultiple(sizeOptions); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/MainViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/MainViewModel.cs similarity index 85% rename from src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/MainViewModel.cs rename to src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/MainViewModel.cs index 89854e129..d14b054f9 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/MainViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/MainViewModel.cs @@ -1,9 +1,8 @@ using CommunityToolkit.Mvvm.ComponentModel; -using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.Attributes; -using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Models; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Controls.VaultList; using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; @@ -13,18 +12,21 @@ using System.Threading; using System.Threading.Tasks; -namespace SecureFolderFS.Sdk.ViewModels +namespace SecureFolderFS.Sdk.ViewModels.Views.Root { - [Inject, Inject, Inject, Inject] + [Inject, Inject, Inject, Inject, Inject(Name = "RootNavigationService", Visibility = "public")] [Bindable(true)] public sealed partial class MainViewModel : ObservableObject, IAsyncInitialize { public IVaultCollectionModel VaultCollectionModel { get; } + public VaultListViewModel VaultListViewModel { get; } + public MainViewModel(IVaultCollectionModel vaultCollectionModel) { ServiceProvider = DI.Default; VaultCollectionModel = vaultCollectionModel; + VaultListViewModel = new(vaultCollectionModel); } /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs new file mode 100644 index 000000000..be2497a6b --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs @@ -0,0 +1,180 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.Contexts; +using SecureFolderFS.Sdk.Enums; +using SecureFolderFS.Sdk.EventArguments; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Messages; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Controls; +using SecureFolderFS.Sdk.ViewModels.Views.Overlays; +using SecureFolderFS.Sdk.ViewModels.Views.Vault; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; + +namespace SecureFolderFS.Sdk.ViewModels.Views.Root +{ + [Inject, Inject, Inject] + [Bindable(true)] + public sealed partial class VaultPreviewViewModel : ObservableObject, IAsyncInitialize, IRecipient, IRecipient, IDisposable + { + private readonly INavigationService _vaultNavigation; + + [ObservableProperty] private UnlockedVaultViewModel? _UnlockedVaultViewModel; + [ObservableProperty] private LoginViewModel? _LoginViewModel; + [ObservableProperty] private VaultViewModel _VaultViewModel; + [ObservableProperty] private bool _IsReadOnlyLogin; + + public VaultPreviewViewModel(VaultViewModel vaultViewModel, INavigationService vaultNavigation) + { + ServiceProvider = DI.Default; + _vaultNavigation = vaultNavigation; + VaultViewModel = vaultViewModel; + + // Create login view model if vault is locked + if (!vaultViewModel.IsUnlocked && vaultViewModel.VaultModel.VaultFolder is { } vaultFolder) + LoginViewModel = new(vaultFolder, LoginViewType.Constrained); + + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + } + + /// + public async Task InitAsync(CancellationToken cancellationToken = default) + { + if (LoginViewModel is null) + return; + + LoginViewModel.VaultUnlocked += LoginViewModel_VaultUnlocked; + await LoginViewModel.InitAsync(cancellationToken); + } + + /// + public void Receive(VaultUnlockedMessage message) + { + if (UnlockedVaultViewModel is not null) + return; + + if (!message.VaultModel.Equals(VaultViewModel.VaultModel)) + return; + + UnlockedVaultViewModel = VaultViewModel.GetUnlockedViewModel(); + } + + /// + public void Receive(VaultLockedMessage message) + { + if (message.VaultModel != VaultViewModel.VaultModel) + return; + + if (VaultViewModel.VaultModel.VaultFolder is null) + return; + + LoginViewModel?.Dispose(); + LoginViewModel = new(VaultViewModel.VaultModel.VaultFolder, LoginViewType.Constrained) { Title = VaultViewModel.Title }; + LoginViewModel.VaultUnlocked += LoginViewModel_VaultUnlocked; + _ = LoginViewModel.InitAsync(); + } + + [RelayCommand] + private async Task RecoverAccessAsync(CancellationToken cancellationToken) + { + if (VaultViewModel.VaultModel.VaultFolder is not { } vaultFolder) + return; + + var recoveryOverlay = new RecoveryOverlayViewModel(vaultFolder); + var result = await OverlayService.ShowAsync(recoveryOverlay); + if (!result.Positive() || recoveryOverlay.UnlockContract is null) + { + recoveryOverlay.Dispose(); + return; + } + + await UnlockAsync(recoveryOverlay.UnlockContract); + } + + [RelayCommand] + private async Task RevealFolderAsync(CancellationToken cancellationToken) + { + if (UnlockedVaultViewModel is not null) + await FileExplorerService.TryOpenInFileExplorerAsync(UnlockedVaultViewModel.StorageRoot.VirtualizedRoot, cancellationToken); + } + + [RelayCommand] + private async Task LockVaultAsync() + { + if (UnlockedVaultViewModel is null) + return; + + // Lock vault + await UnlockedVaultViewModel.DisposeAsync(); + + // Prepare login page + var loginPageViewModel = new VaultLoginViewModel(UnlockedVaultViewModel.VaultViewModel, _vaultNavigation); + _ = loginPageViewModel.InitAsync(); + + // Navigate away + await _vaultNavigation.ForgetNavigateSpecificViewAsync(loginPageViewModel, x => (x as IVaultViewContext)?.VaultViewModel.VaultModel.Equals(UnlockedVaultViewModel.VaultViewModel.VaultModel) ?? false); + WeakReferenceMessenger.Default.Send(new VaultLockedMessage(UnlockedVaultViewModel.VaultViewModel.VaultModel)); + } + + private async Task UnlockAsync(IDisposable unlockContract) + { + try + { + // Unlock the vault + UnlockedVaultViewModel = await VaultViewModel.UnlockAsync(unlockContract, IsReadOnlyLogin); + + // Setup dashboard + var dashboardNavigation = DI.Service(); + var dashboardViewModel = new VaultDashboardViewModel(UnlockedVaultViewModel, _vaultNavigation, dashboardNavigation); + + // Navigate to dashboard + await _vaultNavigation.ForgetNavigateSpecificViewAsync( + dashboardViewModel, + viewFinder: x => (x as IVaultViewContext)?.VaultViewModel.VaultModel.Equals(VaultViewModel.VaultModel) ?? false, + addViewIfMissing: true); + + // Show vault tutorial + if (SettingsService.AppSettings.ShouldShowVaultTutorial) + { + var explanationOverlay = new ExplanationOverlayViewModel(); + await explanationOverlay.InitAsync(); + await OverlayService.ShowAsync(explanationOverlay); + + SettingsService.AppSettings.ShouldShowVaultTutorial = false; + await SettingsService.AppSettings.TrySaveAsync(); + } + } + finally + { + // Clean up login view model + LoginViewModel?.Dispose(); + LoginViewModel = null; + } + } + + private async void LoginViewModel_VaultUnlocked(object? sender, VaultUnlockedEventArgs e) + { + await UnlockAsync(e.UnlockContract); + } + + /// + public void Dispose() + { + WeakReferenceMessenger.Default.UnregisterAll(this); + if (LoginViewModel is not null) + { + LoginViewModel.VaultUnlocked -= LoginViewModel_VaultUnlocked; + LoginViewModel.Dispose(); + } + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/AboutSettingsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/AboutSettingsViewModel.cs index 4c83c80e1..d72c23e21 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/AboutSettingsViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/AboutSettingsViewModel.cs @@ -51,6 +51,12 @@ private Task OpenLicensesAsync() return OverlayService.ShowAsync(new LicensesOverlayViewModel()); } + [RelayCommand(AllowConcurrentExecutions = true)] + private Task OpenOnboardingAsync() + { + return OverlayService.ShowAsync(new IntroductionOverlayViewModel()); + } + [RelayCommand] private async Task CopyAppVersionAsync(CancellationToken cancellationToken) { diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/GeneralSettingsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/GeneralSettingsViewModel.cs index b376aaa8f..6509325e3 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/GeneralSettingsViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/GeneralSettingsViewModel.cs @@ -1,11 +1,14 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels.Controls; using SecureFolderFS.Sdk.ViewModels.Controls.Banners; using SecureFolderFS.Shared; +using SecureFolderFS.Storage.Pickers; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Globalization; @@ -15,7 +18,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Settings { - [Inject, Inject] + [Inject, Inject, Inject] [Bindable(true)] public sealed partial class GeneralSettingsViewModel : BaseSettingsViewModel { @@ -62,6 +65,36 @@ private Task RestartAsync() return ApplicationService.TryRestartAsync(); } + [RelayCommand] + private async Task ExportSettingsAsync(CancellationToken cancellationToken) + { + await using var exportStream = await UserSettings.ExportAsync(cancellationToken); + if (exportStream == System.IO.Stream.Null) + return; + + var filter = new Dictionary() + { + { Constants.Settings.EXPORTED_ARCHIVE_FILENAME, Constants.Settings.EXPORTED_ARCHIVE_EXTENSION } + }; + await FileExplorerService.SaveFileAsync(Constants.Settings.EXPORTED_ARCHIVE_FILENAME, exportStream, filter, cancellationToken); + } + + [RelayCommand] + private async Task ImportSettingsAsync(CancellationToken cancellationToken) + { + var pickedFile = await FileExplorerService.PickFileAsync(new NameFilter([ Constants.Settings.EXPORTED_ARCHIVE_EXTENSION ]), false, cancellationToken); + if (pickedFile is null) + return; + + await using var fileStream = await pickedFile.OpenReadAsync(cancellationToken); + var success = await UserSettings.ImportAsync(fileStream, cancellationToken); + if (!success) + return; + + // Settings were imported successfully, a restart is recommended + IsRestartRequired = true; + } + async partial void OnSelectedLanguageChanged(LanguageViewModel? value) { if (value is null || _noNotify) diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PrivacySettingsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PrivacySettingsViewModel.cs index b7d5993df..5e1f38554 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PrivacySettingsViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PrivacySettingsViewModel.cs @@ -1,4 +1,5 @@ -using SecureFolderFS.Sdk.Attributes; +using CommunityToolkit.Mvvm.Input; +using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.Services.Settings; using SecureFolderFS.Shared; @@ -9,7 +10,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Settings { - [Inject, Inject] + [Inject, Inject, Inject] [Bindable(true)] public sealed partial class PrivacySettingsViewModel : BaseSettingsViewModel { @@ -19,12 +20,6 @@ public PrivacySettingsViewModel() UserSettings.PropertyChanged += UserSettings_PropertyChanged; } - public bool DisableRecentAccess - { - get => UserSettings.DisableRecentAccess; - set => UserSettings.DisableRecentAccess = value; - } - public bool LockOnSystemLock { get => UserSettings.LockOnSystemLock; @@ -43,6 +38,12 @@ public override Task InitAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } + [RelayCommand] + private async Task ClearTracesAsync(CancellationToken cancellationToken) + { + await PrivacyService.ClearTracesAsync(cancellationToken); + } + private async void UserSettings_PropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName != nameof(IUserSettings.IsTelemetryEnabled)) diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs index 988f1af62..0846f345b 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs @@ -95,9 +95,13 @@ public override void OnDisappearing() TransferViewModel.IsPickingFolder = true; await OuterNavigator.NavigateAsync(this); - var cts = TransferViewModel.GetCancellation(); + using var cts = TransferViewModel.GetCancellation(cancellationToken); return await TransferViewModel.PickFolderAsync(new TransferOptions(TransferType.Select), false, cts.Token); } + catch (OperationCanceledException) + { + return null; + } finally { await OuterNavigator.GoBackAsync(); @@ -231,7 +235,7 @@ protected virtual async Task ImportItemAsync(string? itemType, CancellationToken return; TransferViewModel.TransferType = TransferType.Copy; - using var cts = TransferViewModel.GetCancellation(); + using var cts = TransferViewModel.GetCancellation(cancellationToken); await TransferViewModel.TransferAsync([ file ], async (item, token) => { var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, token); @@ -248,7 +252,7 @@ await TransferViewModel.TransferAsync([ file ], async (item, token) => return; TransferViewModel.TransferType = TransferType.Copy; - using var cts = TransferViewModel.GetCancellation(); + using var cts = TransferViewModel.GetCancellation(cancellationToken); await TransferViewModel.TransferAsync([ folder ], async (item, reporter, token) => { var copiedFolder = await modifiableFolder.CreateCopyOfAsync(item, false, reporter, token); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.Scanning.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.Scanning.cs index cd1d88504..b6b4a1fa8 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.Scanning.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.Scanning.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.Input; using SecureFolderFS.Sdk.EventArguments; using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Models; using SecureFolderFS.Shared.Extensions; using System; using System.Threading; @@ -34,7 +35,56 @@ private async Task StartScanningAsync(string? mode) // Begin scanning await Task.Delay(10); - _ = Task.Run(() => ScanAsync(includeFileContents, _cts?.Token ?? default)); + _ = Task.Run(async () => await ScanAsync(_healthModel, includeFileContents, _cts?.Token ?? default)); + await Task.Yield(); + } + + [RelayCommand] + private async Task ScanFolderAsync(string? mode) + { + if (_contentFolder is null) + return; + + // Prompt user to pick a folder + var pickedFolder = await FileExplorerService.PickFolderAsync(null, false); + if (pickedFolder is null) + return; + + // Verify the picked folder is within the vault's content folder + var contentFolderId = _contentFolder.Id.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); + var pickedFolderId = pickedFolder.Id.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); + if (!pickedFolderId.StartsWith(contentFolderId, StringComparison.OrdinalIgnoreCase)) + return; + + // Set IsProgressing status + IsProgressing = true; + Title = StatusTitle = "Scanning..."; + + // Save last scan state + _savedState.AddMultiple(FoundIssues); + FoundIssues.Clear(); + + // Store scan mode + _lastScanMode = mode; + + var includeFileContents = mode?.Contains("include_file_contents", StringComparison.OrdinalIgnoreCase) ?? false; + + // Create a health model for the specific folder + var folderHealthModel = CreateHealthModel(pickedFolder); + + // Begin scanning + await Task.Delay(10); + _ = Task.Run(async () => + { + try + { + await ScanAsync(folderHealthModel, includeFileContents, _cts?.Token ?? default); + } + finally + { + folderHealthModel.Dispose(); + } + }); await Task.Yield(); } @@ -51,14 +101,11 @@ private void CancelScanning() StateChanged?.Invoke(this, new ScanningFinishedEventArgs(true)); } - private async Task ScanAsync(bool includeFileContents, CancellationToken cancellationToken) + private async Task ScanAsync(IHealthModel healthModel, bool includeFileContents, CancellationToken cancellationToken) { - if (_healthModel is null) - return; - // Begin scanning StateChanged?.Invoke(this, new ScanningStartedEventArgs()); - await _healthModel.ScanAsync(includeFileContents, cancellationToken).ConfigureAwait(false); + await healthModel.ScanAsync(includeFileContents, cancellationToken).ConfigureAwait(false); // Finish scanning EndScanning(); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.cs index 52c561369..5a9337571 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultHealthViewModel.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; +using OwlCore.Storage; using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Enums; @@ -24,11 +25,12 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Vault { [Bindable(true)] - [Inject, Inject] + [Inject, Inject, Inject] public sealed partial class VaultHealthViewModel : ObservableObject, IProgress, IProgress, INotifyStateChanged, IViewable, IAsyncInitialize, IDisposable { private CancellationTokenSource? _cts; private IHealthModel? _healthModel; + private IFolder? _contentFolder; private string? _lastScanMode; private readonly SynchronizationContext? _context; private readonly List _savedState; @@ -62,14 +64,25 @@ public VaultHealthViewModel(UnlockedVaultViewModel unlockedVaultViewModel) /// public async Task InitAsync(CancellationToken cancellationToken = default) { - var contentFolder = await VaultHelpers.GetContentFolderAsync(_unlockedVaultViewModel.VaultFolder, cancellationToken); - var folderScanner = new DeepFolderScanner(contentFolder, predicate: x => !VaultService.IsNameReserved(x.Name)); + _contentFolder = await VaultHelpers.GetContentFolderAsync(_unlockedVaultViewModel.VaultFolder, cancellationToken); + var folderScanner = new DeepFolderScanner(_contentFolder, predicate: x => !VaultService.IsNameReserved(x.Name)); var structureValidator = _unlockedVaultViewModel.StorageRoot.Options.HealthStatistics.StructureValidator; + var fileContentValidator = _unlockedVaultViewModel.StorageRoot.Options.HealthStatistics.FileContentValidator; - _healthModel = new HealthModel(folderScanner, new(this, this), structureValidator); + _healthModel = new HealthModel(folderScanner, new(this, this), structureValidator, fileContentValidator); _healthModel.IssueFound += HealthModel_IssueFound; } + internal IHealthModel CreateHealthModel(IFolder folder) + { + var folderScanner = new DeepFolderScanner(folder, predicate: x => !VaultService.IsNameReserved(x.Name)); + var structureValidator = _unlockedVaultViewModel.StorageRoot.Options.HealthStatistics.StructureValidator; + var fileContentValidator = _unlockedVaultViewModel.StorageRoot.Options.HealthStatistics.FileContentValidator; + var healthModel = new HealthModel(folderScanner, new(this, this), structureValidator, fileContentValidator); + healthModel.IssueFound += HealthModel_IssueFound; + return healthModel; + } + /// public void Report(double value) { @@ -173,6 +186,7 @@ public void Dispose() _cts?.TryCancel(); _cts?.Dispose(); FoundIssues.CollectionChanged -= FoundIssues_CollectionChanged; + if (_healthModel is not null) { _healthModel.IssueFound -= HealthModel_IssueFound; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs index d3c04e67c..b7c718d9f 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs @@ -23,8 +23,11 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Vault [Bindable(true)] public sealed partial class VaultLoginViewModel : BaseDesignationViewModel, IVaultViewContext, INavigatable, IAsyncInitialize, IDisposable { + private CancellationTokenSource? _connectionCts; + [ObservableProperty] private bool _IsReadOnly; [ObservableProperty] private bool _IsConnected; + [ObservableProperty] private bool _IsProgressing; [ObservableProperty] private LoginViewModel? _LoginViewModel; public INavigationService VaultNavigation { get; } @@ -52,10 +55,19 @@ public async Task InitAsync(CancellationToken cancellationToken = default) if (LoginViewModel is null) return; - IsConnected = VaultViewModel.VaultModel.IsRemote; - LoginViewModel.VaultUnlocked += LoginViewModel_VaultUnlocked; - await LoginViewModel.InitAsync(cancellationToken); + try + { + IsConnected = VaultViewModel.VaultModel.IsRemote; + LoginViewModel.VaultUnlocked += LoginViewModel_VaultUnlocked; + IsProgressing = true; + await Task.Delay(100, cancellationToken); // Wait for the UI to update + await LoginViewModel.InitAsync(cancellationToken); + } + finally + { + IsProgressing = false; + } #region Test for quick unlock on mobile #if DEBUG @@ -85,16 +97,38 @@ public async Task InitAsync(CancellationToken cancellationToken = default) [RelayCommand] private async Task ConnectToVaultAsync(CancellationToken cancellationToken) { - LoginViewModel?.Dispose(); - var result = await VaultViewModel.VaultModel.TryConnectAsync(cancellationToken); - if (!result.TryGetValue(out var vaultFolder)) + // Cancel any previous connection attempt and create a new CTS + CancelConnection(); + _connectionCts = new CancellationTokenSource(); + + // Link the command's token with our controllable token + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token); + + try { - // TODO: Report error - return; - } + LoginViewModel?.Dispose(); + var result = await VaultViewModel.VaultModel.TryConnectAsync(linkedCts.Token); + if (!result.TryGetValue(out var vaultFolder)) + { + // TODO: Report error + return; + } + + IsProgressing = true; + await Task.Delay(100, linkedCts.Token); // Wait for the UI to update - LoginViewModel = new(vaultFolder, LoginViewType.Full) { Title = VaultViewModel.Title }; - await InitAsync(cancellationToken); + LoginViewModel = new(vaultFolder, LoginViewType.Full) { Title = VaultViewModel.Title }; + await InitAsync(linkedCts.Token); + } + catch (OperationCanceledException) + { + LoginViewModel?.Dispose(); + LoginViewModel = null; + } + finally + { + IsProgressing = false; + } } [RelayCommand] @@ -106,6 +140,17 @@ private async Task DisconnectFromVaultAsync(CancellationToken cancellationToken) IsConnected = false; } + [RelayCommand] + private void CancelConnection() + { + if (_connectionCts is null) + return; + + _connectionCts.TryCancel(); + _connectionCts.Dispose(); + _connectionCts = null; + } + [RelayCommand] private async Task BeginRecoveryAsync(CancellationToken cancellationToken) { @@ -157,6 +202,9 @@ private async void LoginViewModel_VaultUnlocked(object? sender, VaultUnlockedEve /// public void Dispose() { + _connectionCts?.TryCancel(); + _connectionCts?.Dispose(); + _connectionCts = null; IsConnected = false; LoginViewModel?.Dispose(); NavigationRequested = null; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs index f5e75eadb..41d7f0d3a 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs @@ -1,26 +1,26 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.EventArguments; using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Models; using SecureFolderFS.Sdk.Results; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels.Controls; using SecureFolderFS.Sdk.ViewModels.Controls.Authentication; +using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using SecureFolderFS.Sdk.Models; -using SecureFolderFS.Sdk.ViewModels.Views.Overlays; namespace SecureFolderFS.Sdk.ViewModels.Views.Wizard { @@ -29,7 +29,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Wizard public sealed partial class CredentialsWizardViewModel : OverlayViewModel, IStagingView { private readonly string _vaultId; - private readonly TaskCompletionSource _credentialsTcs; + private readonly TaskCompletionSource _credentialsTcs; [ObservableProperty] private bool _IsNameCipherEnabled; [ObservableProperty] private PickerOptionViewModel? _ContentCipher; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/AccountSourceWizardViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/AccountSourceWizardViewModel.cs index 241931b50..bba20b96e 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/AccountSourceWizardViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/DataSources/AccountSourceWizardViewModel.cs @@ -167,22 +167,18 @@ private async Task SelectAccountAsync(AccountViewModel? accountViewModel, Cancel // It is expected that the existing connection will return the root folder immediately var rootFolder = await accountViewModel.ConnectAsync(cancellationToken); var browser = BrowserHelpers.CreateBrowser(rootFolder, new FileSystemOptions(), accountViewModel, outerNavigator: this); - try - { - // Prompt the user to pick a folder - browser.OnAppearing(); - _selectedFolder = await browser.PickFolderAsync(null, true, cancellationToken); - - // Update CanContinue - var result = await ValidationHelpers.ValidateAddedVault(_selectedFolder, Mode, VaultCollectionModel.Select(x => x.DataModel), cancellationToken); - Message = result.Message; - CanContinue = result.CanContinue; - SelectedLocation = result.SelectedLocation; - } - finally - { - browser.OnDisappearing(); - } + + // Prompt the user to pick a folder + browser.OnAppearing(); + _selectedFolder = await browser.PickFolderAsync(null, true, cancellationToken); + if (_selectedFolder is null) + return; + + // Update CanContinue + var result = await ValidationHelpers.ValidateAddedVault(_selectedFolder, Mode, VaultCollectionModel.Select(x => x.DataModel), cancellationToken); + Message = result.Message; + CanContinue = result.CanContinue; + SelectedLocation = result.SelectedLocation; } catch (Exception ex) { diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/IAuthenticator.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/IAuthenticator.cs index e0511bec8..18315acbf 100644 --- a/src/Shared/SecureFolderFS.Shared/ComponentModel/IAuthenticator.cs +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/IAuthenticator.cs @@ -22,8 +22,13 @@ public interface IAuthenticator /// The persistent ID that uniquely identifies the individual authentication transaction. /// The data that represents the key material or the data to sign. /// A that cancels this action. - /// A that represents the asynchronous operation. If successful, value is that represents the key material for authentication. - Task EnrollAsync(string id, byte[]? data, CancellationToken cancellationToken = default); + /// A that represents the asynchronous operation. If successful, value is an of that represents the key material for authentication. + /// + /// Despite returning , this method is expected to throw exceptions for critical errors such as user cancellation or system failures. + /// The is intended to capture additional authentication data, if any. + /// The handler is solely responsible for the handling of exceptions, and the method should not suppress or convert exceptions into result values. + /// + Task> EnrollAsync(string id, byte[]? data, CancellationToken cancellationToken = default); /// /// Authenticates the user asynchronously. @@ -31,7 +36,12 @@ public interface IAuthenticator /// The persistent ID that uniquely identifies the individual authentication transaction. /// The data that represents the ciphertext material or the data to sign. /// A that cancels this action. - /// A that represents the asynchronous operation. If successful, value is that represents the key material for authentication. - Task AcquireAsync(string id, byte[]? data, CancellationToken cancellationToken = default); + /// A that represents the asynchronous operation. If successful, value is an of that represents the key material for authentication. + /// + /// Despite returning , this method is expected to throw exceptions for critical errors such as user cancellation or system failures. + /// The is intended to capture additional authentication data, if any. + /// The handler is solely responsible for the handling of exceptions, and the method should not suppress or convert exceptions into result values. + /// + Task> AcquireAsync(string id, byte[]? data, CancellationToken cancellationToken = default); } } diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/IChangeTracker.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/IChangeTracker.cs new file mode 100644 index 000000000..2e3ce51c3 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/IChangeTracker.cs @@ -0,0 +1,13 @@ +namespace SecureFolderFS.Shared.ComponentModel +{ + /// + /// Represents a component that tracks whether changes have occurred. + /// + public interface IChangeTracker + { + /// + /// Gets a value indicating whether the tracked item has been modified. + /// + bool WasModified { get; } + } +} diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/IKey.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/IKey.cs index b6176af70..84430e212 100644 --- a/src/Shared/SecureFolderFS.Shared/ComponentModel/IKey.cs +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/IKey.cs @@ -1,16 +1,68 @@ using System; -using System.Collections.Generic; +using System.Buffers; namespace SecureFolderFS.Shared.ComponentModel { /// - /// Represents a byte sequence of a key that can be disposed. + /// Represents a key material that can be disposed of. /// - public interface IKey : IEnumerable, IDisposable + public interface IKey : ILengthMeasurable, IDisposable + { + } + + /// + /// Represents a disposable key material that can expose its byte sequence. + /// + public interface IKeyBytes : IKeyUsage + { + /// + /// Gets the byte sequence representing the key. + /// + byte[] Key { get; } + } + + /// + /// Represents a key as a disposable byte sequence with actions to use it as a readonly span of bytes. + /// + public interface IKeyUsage : IKey { /// - /// Gets the number of bytes in the key. + /// Executes the provided action with the key represented as a of bytes. /// - int Length { get; } + /// An action to execute, receiving the key as a of bytes. + void UseKey(Action> keyAction); + + /// + /// Executes the provided action with the key and additional state. + /// This overload allows passing state to avoid lambda capture issues with spans. + /// + /// The type of the state parameter. + /// The state to pass to the action. + /// An action to execute, receiving the key and state. + void UseKey(TState state, ReadOnlySpanAction keyAction); + + /// + /// Executes the provided action with the key represented as a of bytes. + /// + /// An action to execute, receiving the key as a of bytes. + /// The type of the result returned by the action. + /// The result of the executed action, of type . + TResult UseKey(Func, TResult> keyAction); + + /// + /// Executes the provided function with the key and additional state, returning a result. + /// This overload allows passing state to avoid lambda capture issues with spans. + /// + /// The type of the state parameter. + /// The type of the result. + /// The state to pass to the function. + /// A function to execute, receiving the key and state. + /// The result of the executed function. + TResult UseKey(TState state, ReadOnlySpanFunc keyAction); } + + /// + /// Represents a function that receives a and state, returning a result. + /// + public delegate TResult ReadOnlySpanFunc(ReadOnlySpan span, TState state); } diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/ILengthMeasurable.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/ILengthMeasurable.cs new file mode 100644 index 000000000..0b3308ca6 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/ILengthMeasurable.cs @@ -0,0 +1,13 @@ +namespace SecureFolderFS.Shared.ComponentModel +{ + /// + /// Represents a countable sequence of elements. + /// + public interface ILengthMeasurable + { + /// + /// Gets the number of elements in the countable sequence. + /// + int Length { get; } + } +} diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/IPassword.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/IPassword.cs index a36d7c77a..f94c3a44a 100644 --- a/src/Shared/SecureFolderFS.Shared/ComponentModel/IPassword.cs +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/IPassword.cs @@ -6,7 +6,7 @@ namespace SecureFolderFS.Shared.ComponentModel /// /// Represents a password that can be cleared. /// - public interface IPassword : IKey + public interface IPassword : IKeyBytes { /// /// Gets the number of characters in the password. diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/MulticastProgress.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/MulticastProgress.cs new file mode 100644 index 000000000..b9c958521 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/MulticastProgress.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading; + +namespace SecureFolderFS.Shared.ComponentModel +{ + /// + /// An implementation of that broadcasts progress reports to multiple subscribers. + /// Optimized for the common case of 2-3 subscribers with zero allocations in the hot path. + /// + /// The type of progress update value. + public sealed class MulticastProgress : IProgress, IDisposable + { + private volatile IProgress? _subscriber1; + private volatile IProgress? _subscriber2; + private volatile IProgress? _subscriber3; + + /// + /// Subscribes an to receive progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + public IDisposable Subscribe(IProgress progress) + { + if (progress is null) + throw new ArgumentNullException(nameof(progress)); + + // Try to assign to the first available slot + if (_subscriber1 is null && Interlocked.CompareExchange(ref _subscriber1, progress, null) is null) + return new Subscription(this, progress, 1); + + if (_subscriber2 is null && Interlocked.CompareExchange(ref _subscriber2, progress, null) is null) + return new Subscription(this, progress, 2); + + if (_subscriber3 is null && Interlocked.CompareExchange(ref _subscriber3, progress, null) is null) + return new Subscription(this, progress, 3); + + throw new InvalidOperationException("Maximum number of subscribers (3) reached."); + } + + /// + public void Report(T value) + { + // Read volatile fields - no lock, no allocation, no array iteration + _subscriber1?.Report(value); + _subscriber2?.Report(value); + _subscriber3?.Report(value); + } + + private void Unsubscribe(IProgress progress, int slot) + { + // Clear the specific slot + switch (slot) + { + case 1: + Interlocked.CompareExchange(ref _subscriber1, null, progress); + break; + case 2: + Interlocked.CompareExchange(ref _subscriber2, null, progress); + break; + case 3: + Interlocked.CompareExchange(ref _subscriber3, null, progress); + break; + } + } + + /// + public void Dispose() + { + _subscriber1 = null; + _subscriber2 = null; + _subscriber3 = null; + } + + private sealed class Subscription : IDisposable + { + private readonly MulticastProgress _parent; + private readonly IProgress _progress; + private readonly int _slot; + private bool _disposed; + + public Subscription(MulticastProgress parent, IProgress progress, int slot) + { + _parent = parent; + _progress = progress; + _slot = slot; + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _parent.Unsubscribe(_progress, _slot); + } + } + } +} + diff --git a/src/Shared/SecureFolderFS.Shared/Extensions/AuthenticationExtensions.cs b/src/Shared/SecureFolderFS.Shared/Extensions/AuthenticationExtensions.cs index eec7ec550..88f1fdec4 100644 --- a/src/Shared/SecureFolderFS.Shared/Extensions/AuthenticationExtensions.cs +++ b/src/Shared/SecureFolderFS.Shared/Extensions/AuthenticationExtensions.cs @@ -8,31 +8,29 @@ namespace SecureFolderFS.Shared.Extensions { public static class AuthenticationExtensions { - public static async Task> TryCreateAsync(this IAuthenticator authenticator, + public static async Task> TryEnrollAsync(this IAuthenticator authenticator, string id, byte[]? data, CancellationToken cancellationToken) { try { - var key = await authenticator.EnrollAsync(id, data, cancellationToken); - return Result.Success(key); + return await authenticator.EnrollAsync(id, data, cancellationToken); } catch (Exception ex) { - return Result.Failure(ex); + return Result.Failure(ex); } } - public static async Task> TrySignAsync(this IAuthenticator authenticator, + public static async Task> TryAcquireAsync(this IAuthenticator authenticator, string id, byte[]? data, CancellationToken cancellationToken) { try { - var key = await authenticator.AcquireAsync(id, data, cancellationToken); - return Result.Success(key); + return await authenticator.AcquireAsync(id, data, cancellationToken); } catch (Exception ex) { - return Result.Failure(ex); + return Result.Failure(ex); } } } diff --git a/src/Shared/SecureFolderFS.Shared/Helpers/FileTypeHelper.cs b/src/Shared/SecureFolderFS.Shared/Helpers/FileTypeHelper.cs index 59d416363..5f06eef44 100644 --- a/src/Shared/SecureFolderFS.Shared/Helpers/FileTypeHelper.cs +++ b/src/Shared/SecureFolderFS.Shared/Helpers/FileTypeHelper.cs @@ -39,6 +39,7 @@ public static string GetMimeType(string name) public static TypeHint GetTypeFromMime(string mimeType) { + mimeType = mimeType.ToLowerInvariant(); return Image() ?? Plaintext() ?? Document() diff --git a/src/Shared/SecureFolderFS.Shared/Models/DisposablePassword.cs b/src/Shared/SecureFolderFS.Shared/Models/DisposablePassword.cs index 9989b18d1..edd0592bc 100644 --- a/src/Shared/SecureFolderFS.Shared/Models/DisposablePassword.cs +++ b/src/Shared/SecureFolderFS.Shared/Models/DisposablePassword.cs @@ -1,50 +1,64 @@ -using SecureFolderFS.Shared.ComponentModel; -using System; -using System.Collections; -using System.Collections.Generic; +using System; +using System.Buffers; +using System.Security.Cryptography; using System.Text; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Shared.Models { /// public sealed class DisposablePassword : IPassword { - private readonly byte[] _password; + /// + public byte[] Key { get; } /// public int CharacterCount { get; } /// - public int Length => _password.Length; + public int Length { get; } public DisposablePassword(string password) { - _password = Encoding.UTF8.GetBytes(password); + Key = Encoding.UTF8.GetBytes(password); + Length = Key.Length; CharacterCount = password.Length; } /// - public IEnumerator GetEnumerator() + public void UseKey(Action> keyAction) + { + keyAction(Key); + } + + /// + public void UseKey(TState state, ReadOnlySpanAction keyAction) { - return ((IEnumerable)_password).GetEnumerator(); + keyAction(Key, state); } /// - IEnumerator IEnumerable.GetEnumerator() + public TResult UseKey(Func, TResult> keyAction) { - return GetEnumerator(); + return keyAction(Key); } /// + public TResult UseKey(TState state, ReadOnlySpanFunc keyAction) + { + return keyAction(Key, state); + } + + /// public new string ToString() { - return Encoding.UTF8.GetString(_password); + return Encoding.UTF8.GetString(Key); } /// public void Dispose() { - Array.Clear(_password); + CryptographicOperations.ZeroMemory(Key); } } } diff --git a/src/Shared/SecureFolderFS.Shared/Models/IVaultUnlockingModel.cs b/src/Shared/SecureFolderFS.Shared/Models/IVaultUnlockingModel.cs index c8365eac7..a1b3c108a 100644 --- a/src/Shared/SecureFolderFS.Shared/Models/IVaultUnlockingModel.cs +++ b/src/Shared/SecureFolderFS.Shared/Models/IVaultUnlockingModel.cs @@ -19,7 +19,7 @@ public interface IVaultUnlockingModel : IDisposable /// The credentials required to unlock the vault. /// A that cancels this action. /// A that represents the asynchronous operation. Value is the unlock contract represented by . - Task UnlockAsync(IKey credentials, CancellationToken cancellationToken = default); + Task UnlockAsync(IKeyBytes credentials, CancellationToken cancellationToken = default); /// /// Recovers the specified vault using the provided . diff --git a/src/Shared/SecureFolderFS.Shared/Models/KeySequence.cs b/src/Shared/SecureFolderFS.Shared/Models/KeySequence.cs index c62feb530..0a16b92f5 100644 --- a/src/Shared/SecureFolderFS.Shared/Models/KeySequence.cs +++ b/src/Shared/SecureFolderFS.Shared/Models/KeySequence.cs @@ -1,19 +1,47 @@ -using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Shared.Extensions; -using System.Collections; +using System; +using System.Buffers; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; namespace SecureFolderFS.Shared.Models { /// - /// A class used to concatenate a collection of keys in a stack-like behavior into one, singular instance. + /// A class used to concatenate a collection of keys in a stack-like behavior into one, singular instance. /// - public sealed class KeySequence : IKey + public sealed class KeySequence : IKeyBytes { - private readonly List _keys; + private byte[]? _combinedKey; + private readonly List _keys; + + public IReadOnlyCollection Keys => _keys; - public IReadOnlyCollection Keys => _keys; + public byte[] Key + { + get + { + if (_combinedKey is not null) + return _combinedKey; + + // Combine all keys into one + var totalLength = _keys.Sum(key => key.Length); + _combinedKey = new byte[totalLength]; + + var offset = 0; + foreach (var key in _keys) + { + key.UseKey(span => + { + span.CopyTo(_combinedKey.AsSpan(offset, key.Length)); + offset += key.Length; + }); + } + + return _combinedKey; + } + } /// /// Gets the number of keys in the sequence. @@ -28,12 +56,38 @@ public KeySequence() _keys = new(); } - public void Add(IKey key) + /// + 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 void Add(IKeyUsage key) { _keys.Add(key); + CryptographicOperations.ZeroMemory(_combinedKey); + _combinedKey = null; } - public void SetOrAdd(int index, IKey key) + public void SetOrAdd(int index, IKeyUsage key) { if (index >= 0 && index < _keys.Count) { @@ -42,33 +96,28 @@ public void SetOrAdd(int index, IKey key) } else { - // If index is out of bounds, add the element to the list + // If the index is out of bounds, add the element to the list _keys.Add(key); } + + CryptographicOperations.ZeroMemory(_combinedKey); + _combinedKey = null; } public void RemoveAt(int index) { _keys.RemoveAt(index); - } - - /// - public IEnumerator GetEnumerator() - { - foreach (var key in _keys) - foreach (var item in key) - yield return item; - } - /// - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); + CryptographicOperations.ZeroMemory(_combinedKey); + _combinedKey = null; } /// public void Dispose() { + if (_combinedKey is not null) + CryptographicOperations.ZeroMemory(_combinedKey); + _keys.DisposeAll(); _keys.Clear(); } diff --git a/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj b/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj index 8c913854b..f2980f881 100644 --- a/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj +++ b/src/Shared/SecureFolderFS.Shared/SecureFolderFS.Shared.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Shared/SecureFolderFS.Shared/SharedConfiguration.cs b/src/Shared/SecureFolderFS.Shared/SharedConfiguration.cs new file mode 100644 index 000000000..2386f064d --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/SharedConfiguration.cs @@ -0,0 +1,10 @@ +namespace SecureFolderFS.Shared +{ + public static class SharedConfiguration + { + /// + /// Enables memory-hardening features such as pinning, page locking and key XOR'ing. + /// + public static bool UseCoreMemoryProtection { get; set; } = true; + } +} diff --git a/src/Shared/SecureFolderFS.Storage/Constants.cs b/src/Shared/SecureFolderFS.Storage/Constants.cs new file mode 100644 index 000000000..520967ee1 --- /dev/null +++ b/src/Shared/SecureFolderFS.Storage/Constants.cs @@ -0,0 +1,30 @@ +namespace SecureFolderFS.Storage +{ + public class Constants + { + public static class Sizes + { + public const long KILOBYTE = 1024; + public const long MEGABYTE = KILOBYTE * 1024; + public const long GIGABYTE = MEGABYTE * 1024; + public const long TERABYTE = GIGABYTE * 1024; + } + + public static class SpecialNames + { + public static string[] IllegalNames { get; } = + [ + // Windows + "con", "prn", "aux", "nul", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + + // Unix + ".", "..", + + // MacOS + ".DS_Store" + ]; + } + } +} diff --git a/src/Shared/SecureFolderFS.Storage/Extensions/StorageExtensions.File.cs b/src/Shared/SecureFolderFS.Storage/Extensions/StorageExtensions.File.cs index b35b9598c..403414b3f 100644 --- a/src/Shared/SecureFolderFS.Storage/Extensions/StorageExtensions.File.cs +++ b/src/Shared/SecureFolderFS.Storage/Extensions/StorageExtensions.File.cs @@ -74,6 +74,12 @@ public static async Task ReadAllTextAsync(this IFile file, Encoding? enc } } + /// + /// Retrieves the size of the specified . + /// + /// The file whose size is to be retrieved. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the size of the file in bytes, or 0 if unavailable. public static async Task GetSizeAsync(this IFile file, CancellationToken cancellationToken = default) { if (file is not IStorableProperties storableProperties) diff --git a/src/Shared/SecureFolderFS.Storage/Extensions/StorageExtensions.Folder.cs b/src/Shared/SecureFolderFS.Storage/Extensions/StorageExtensions.Folder.cs index 45f22b966..bdda53a5f 100644 --- a/src/Shared/SecureFolderFS.Storage/Extensions/StorageExtensions.Folder.cs +++ b/src/Shared/SecureFolderFS.Storage/Extensions/StorageExtensions.Folder.cs @@ -65,6 +65,7 @@ public static async Task GetItemByRelativePathOrSelfAsync(this IStora .TrimStart() .TrimStart(Path.AltDirectorySeparatorChar) .TrimStart(Path.DirectorySeparatorChar); + return await from.GetItemByRelativePathAsync(relativePathWithoutRoot, cancellationToken); } @@ -110,7 +111,7 @@ public static async Task GetItemRecursiveOrSelfAsync(this IFolder fol } /// - public static async Task TryGetFirstByNameAsync(this IFolder folder, string name, CancellationToken cancellationToken = default) + public static async Task TryGetFirstByNameAsync(this IFolder folder, string name, CancellationToken cancellationToken = default) { try { @@ -122,6 +123,32 @@ public static async Task GetItemRecursiveOrSelfAsync(this IFolder fol } } + /// + public static async Task TryGetFileByNameAsync(this IFolder folder, string fileName, CancellationToken cancellationToken = default) + { + try + { + return await folder.GetFileByNameAsync(fileName, cancellationToken); + } + catch (Exception) + { + return null; + } + } + + /// + public static async Task TryGetFolderByNameAsync(this IFolder folder, string folderName, CancellationToken cancellationToken = default) + { + try + { + return await folder.GetFolderByNameAsync(folderName, cancellationToken); + } + catch (Exception) + { + return null; + } + } + public static async Task GetSizeAsync(this IFolder folder, CancellationToken cancellationToken = default) { if (folder is IStorableProperties storableProperties) diff --git a/src/Shared/SecureFolderFS.Storage/SpecialNames.cs b/src/Shared/SecureFolderFS.Storage/SpecialNames.cs deleted file mode 100644 index 0bb7264f4..000000000 --- a/src/Shared/SecureFolderFS.Storage/SpecialNames.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace SecureFolderFS.Storage -{ - public static class SpecialNames - { - public static string[] IllegalNames { get; } = - [ - // Windows - "con", "prn", "aux", "nul", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", - - // Unix - ".", "..", - - // MacOS - ".DS_Store" - ]; - } -} diff --git a/src/Shared/SecureFolderFS.Storage/SystemStorageEx/StorageProperties/SystemFileExProperties.cs b/src/Shared/SecureFolderFS.Storage/SystemStorageEx/StorageProperties/SystemFileExProperties.cs new file mode 100644 index 000000000..a9504a946 --- /dev/null +++ b/src/Shared/SecureFolderFS.Storage/SystemStorageEx/StorageProperties/SystemFileExProperties.cs @@ -0,0 +1,56 @@ +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.Storage.StorageProperties; + +namespace SecureFolderFS.Storage.SystemStorageEx.StorageProperties +{ + public sealed class SystemFileExProperties : IBasicProperties, ISizeProperties, IDateProperties + { + private readonly FileInfo _fileInfo; + + public SystemFileExProperties(FileInfo fileInfo) + { + _fileInfo = fileInfo; + } + + /// + public async Task?> GetSizeAsync(CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + var size = _fileInfo.Length; + + return new GenericProperty(size); + } + + /// + public async Task> GetDateCreatedAsync(CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + var created = _fileInfo.CreationTime; + + return new GenericProperty(created); + } + + /// + public async Task> GetDateModifiedAsync(CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + var modified = _fileInfo.LastWriteTime; + + return new GenericProperty(modified); + } + + /// + 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/Shared/SecureFolderFS.Storage/SystemStorageEx/StorageProperties/SystemFolderExProperties.cs b/src/Shared/SecureFolderFS.Storage/SystemStorageEx/StorageProperties/SystemFolderExProperties.cs new file mode 100644 index 000000000..554d027a5 --- /dev/null +++ b/src/Shared/SecureFolderFS.Storage/SystemStorageEx/StorageProperties/SystemFolderExProperties.cs @@ -0,0 +1,46 @@ +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.Storage.StorageProperties; + +namespace SecureFolderFS.Storage.SystemStorageEx.StorageProperties +{ + public sealed class SystemFolderExProperties : IBasicProperties, IDateProperties + { + private readonly DirectoryInfo _directoryInfo; + + public SystemFolderExProperties(DirectoryInfo directoryInfo) + { + _directoryInfo = directoryInfo; + } + + /// + public async Task> GetDateCreatedAsync(CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + var created = _directoryInfo.CreationTime; + + return new GenericProperty(created); + } + + /// + public async Task> GetDateModifiedAsync(CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + var modified = _directoryInfo.LastWriteTime; + + return new GenericProperty(modified); + } + + /// + 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/Shared/SecureFolderFS.Storage/SystemStorageEx/SystemFileEx.cs b/src/Shared/SecureFolderFS.Storage/SystemStorageEx/SystemFileEx.cs index ecd3b6588..9e56bc40b 100644 --- a/src/Shared/SecureFolderFS.Storage/SystemStorageEx/SystemFileEx.cs +++ b/src/Shared/SecureFolderFS.Storage/SystemStorageEx/SystemFileEx.cs @@ -1,14 +1,18 @@ -using OwlCore.Storage; -using OwlCore.Storage.System.IO; -using System.IO; +using System.IO; using System.Threading; using System.Threading.Tasks; +using OwlCore.Storage; +using OwlCore.Storage.System.IO; +using SecureFolderFS.Storage.StorageProperties; +using SecureFolderFS.Storage.SystemStorageEx.StorageProperties; namespace SecureFolderFS.Storage.SystemStorageEx { /// - public class SystemFileEx : SystemFile + public class SystemFileEx : SystemFile, IStorableProperties { + protected IBasicProperties? properties; + /// public SystemFileEx(string path) : base(path) @@ -31,8 +35,18 @@ public SystemFileEx(FileInfo info) /// public override Task GetRootAsync(CancellationToken cancellationToken = default) { - var root = new DirectoryInfo(Path).Root; + var root = Info.Directory?.Root; + if (root is null) + return Task.FromResult(null); + return Task.FromResult(new SystemFolderEx(root)); } + + /// + public Task GetPropertiesAsync() + { + properties ??= new SystemFileExProperties(Info); + return Task.FromResult(properties); + } } } diff --git a/src/Shared/SecureFolderFS.Storage/SystemStorageEx/SystemFolderEx.cs b/src/Shared/SecureFolderFS.Storage/SystemStorageEx/SystemFolderEx.cs index 27b6801e8..38b6360df 100644 --- a/src/Shared/SecureFolderFS.Storage/SystemStorageEx/SystemFolderEx.cs +++ b/src/Shared/SecureFolderFS.Storage/SystemStorageEx/SystemFolderEx.cs @@ -1,19 +1,22 @@ -using OwlCore.Storage; -using OwlCore.Storage.System.IO; -using SecureFolderFS.Storage.Renamable; -using System; +using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using OwlCore.Storage; +using OwlCore.Storage.System.IO; +using SecureFolderFS.Storage.Renamable; +using SecureFolderFS.Storage.StorageProperties; +using SecureFolderFS.Storage.SystemStorageEx.StorageProperties; namespace SecureFolderFS.Storage.SystemStorageEx { /// - public class SystemFolderEx : SystemFolder, IRenamableFolder + public class SystemFolderEx : SystemFolder, IRenamableFolder, IStorableProperties { + protected IBasicProperties? poperties; + /// public SystemFolderEx(string path) : base(path) @@ -52,7 +55,7 @@ public override async IAsyncEnumerable GetItemsAsync(StorableTyp { await foreach (var item in base.GetItemsAsync(type, cancellationToken)) { - if (SpecialNames.IllegalNames.Contains(item.Name, StringComparer.OrdinalIgnoreCase)) + if (Constants.SpecialNames.IllegalNames.Contains(item.Name, StringComparer.OrdinalIgnoreCase)) continue; yield return item switch @@ -135,8 +138,15 @@ public override async Task CreateFileAsync(string name, bool overwri /// public override Task GetRootAsync(CancellationToken cancellationToken = default) { - var root = new DirectoryInfo(Path).Root; + var root = Info.Root; return Task.FromResult(new SystemFolderEx(root)); } + + /// + public Task GetPropertiesAsync() + { + poperties ??= new SystemFolderExProperties(Info); + return Task.FromResult(poperties); + } } } diff --git a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IFileSystemStatisticsSubscriber.cs b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IFileSystemStatisticsSubscriber.cs new file mode 100644 index 000000000..f297bcc62 --- /dev/null +++ b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IFileSystemStatisticsSubscriber.cs @@ -0,0 +1,61 @@ +using SecureFolderFS.Shared.Enums; +using System; + +namespace SecureFolderFS.Storage.VirtualFileSystem +{ + /// + /// Extends with subscription capabilities to support multiple subscribers. + /// + public interface IFileSystemStatisticsSubscriber : IFileSystemStatistics + { + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + IDisposable SubscribeToBytesRead(IProgress progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + IDisposable SubscribeToBytesWritten(IProgress progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + IDisposable SubscribeToBytesEncrypted(IProgress progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + IDisposable SubscribeToBytesDecrypted(IProgress progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + IDisposable SubscribeToChunkCache(IProgress progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + IDisposable SubscribeToFileNameCache(IProgress progress); + + /// + /// Subscribes to progress reports. + /// + /// The progress instance to subscribe. + /// An that can be used to unsubscribe. + IDisposable SubscribeToDirectoryIdCache(IProgress progress); + } +} + diff --git a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IHealthStatistics.cs b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IHealthStatistics.cs index 8d2c14026..79cbfb178 100644 --- a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IHealthStatistics.cs +++ b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/IHealthStatistics.cs @@ -1,4 +1,4 @@ -using OwlCore.Storage; +using OwlCore.Storage; using SecureFolderFS.Shared.ComponentModel; using System; @@ -11,6 +11,11 @@ public interface IHealthStatistics /// IAsyncValidator? FileValidator { get; set; } + /// + /// Gets the file content validator for deep file validation. + /// + IAsyncValidator? FileContentValidator { get; set; } + /// /// Gets the file health validator associated with this instance. /// diff --git a/tests/SecureFolderFS.Tests/GlobalSetup.cs b/tests/SecureFolderFS.Tests/GlobalSetup.cs index 5f4d6c3dc..8b732e6c9 100644 --- a/tests/SecureFolderFS.Tests/GlobalSetup.cs +++ b/tests/SecureFolderFS.Tests/GlobalSetup.cs @@ -19,11 +19,11 @@ public static void GlobalInitialize() var settingsFolderPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), Constants.FileNames.SETTINGS_FOLDER_NAME); var settingsFolder = new MemoryFolder(settingsFolderPath, Path.GetFileName(settingsFolderPath)); - var serviceProvider = BuildServiceProvider(settingsFolder); + var serviceProvider = ConfigureServices(settingsFolder); DI.Default.SetServiceProvider(serviceProvider); } - private static IServiceProvider BuildServiceProvider(IModifiableFolder settingsFolder) + private static IServiceProvider ConfigureServices(IModifiableFolder settingsFolder) { return new ServiceCollection() diff --git a/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj b/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj index 8861dc8b4..b7a6c8c8c 100644 --- a/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj +++ b/tests/SecureFolderFS.Tests/SecureFolderFS.Tests.csproj @@ -11,13 +11,14 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/tests/SecureFolderFS.Tests/ServiceImplementation/MockLocalizationService.cs b/tests/SecureFolderFS.Tests/ServiceImplementation/MockLocalizationService.cs new file mode 100644 index 000000000..4ae2d9bf5 --- /dev/null +++ b/tests/SecureFolderFS.Tests/ServiceImplementation/MockLocalizationService.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using SecureFolderFS.Sdk.Services; + +namespace SecureFolderFS.Tests.ServiceImplementation +{ + /// + internal sealed class MockLocalizationService : ILocalizationService + { + private static readonly Dictionary Resources = new() + { + { "DateToday", "Today, {0}" }, + { "DateYesterday", "Yesterday, {0}" }, + { "DateDaysAgo", "{0} days ago" }, + { "DateWeekAgo", "Last week" } + }; + + /// + public CultureInfo CurrentCulture { get; } = CultureInfo.InvariantCulture; + + /// + public IReadOnlyList AppLanguages { get; } = [CultureInfo.InvariantCulture]; + + /// + public string? GetResource(string resourceKey) + { + return Resources.GetValueOrDefault(resourceKey); + } + + /// + public Task SetCultureAsync(CultureInfo cultureInfo) + { + return Task.CompletedTask; + } + } +} + diff --git a/tests/SecureFolderFS.Tests/ServiceTests/LocalizationServiceTests.cs b/tests/SecureFolderFS.Tests/ServiceTests/LocalizationServiceTests.cs new file mode 100644 index 000000000..ac1b486d1 --- /dev/null +++ b/tests/SecureFolderFS.Tests/ServiceTests/LocalizationServiceTests.cs @@ -0,0 +1,109 @@ +using FluentAssertions; +using NUnit.Framework; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Tests.ServiceImplementation; + +namespace SecureFolderFS.Tests.ServiceTests +{ + [TestFixture] + public class LocalizationServiceTests + { + private MockLocalizationService _localizationService = null!; + + [SetUp] + public void SetUp() + { + _localizationService = new MockLocalizationService(); + } + + [Test] + public void LocalizeDate_UnspecifiedDate_ReturnsUnspecified() + { + // Arrange + var date = new DateTime(1, 1, 1); + + // Act + var result = _localizationService.LocalizeDate(date); + + // Assert + result.Should().Be("Unspecified"); + } + + [Test] + public void LocalizeDate_Today_ReturnsFormattedToday() + { + // Arrange + var date = DateTime.Today.AddHours(14).AddMinutes(30); + var expectedTime = date.ToString("t", _localizationService.CurrentCulture); + + // Act + var result = _localizationService.LocalizeDate(date); + + // Assert + result.Should().Be($"Today, {expectedTime}"); + } + + [Test] + public void LocalizeDate_Yesterday_ReturnsFormattedYesterday() + { + // Arrange + var date = DateTime.Today.AddDays(-1).AddHours(10).AddMinutes(15); + var expectedTime = date.ToString("t", _localizationService.CurrentCulture); + + // Act + var result = _localizationService.LocalizeDate(date); + + // Assert + result.Should().Be($"Yesterday, {expectedTime}"); + } + + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + public void LocalizeDate_TwoToSixDaysAgo_ReturnsDaysAgo(int daysAgo) + { + // Arrange + var date = DateTime.Today.AddDays(-daysAgo); + + // Act + var result = _localizationService.LocalizeDate(date); + + // Assert + result.Should().Be($"{daysAgo} days ago"); + } + + [TestCase(7)] + [TestCase(10)] + [TestCase(13)] + public void LocalizeDate_SevenToThirteenDaysAgo_ReturnsLastWeek(int daysAgo) + { + // Arrange + var date = DateTime.Today.AddDays(-daysAgo); + + // Act + var result = _localizationService.LocalizeDate(date); + + // Assert + result.Should().Be("Last week"); + } + + [TestCase(14)] + [TestCase(30)] + [TestCase(365)] + public void LocalizeDate_FourteenOrMoreDaysAgo_ReturnsFallbackFormat(int daysAgo) + { + // Arrange + var date = DateTime.Today.AddDays(-daysAgo).AddHours(9).AddMinutes(45); + var expectedDate = date.ToString("d", _localizationService.CurrentCulture); + var expectedTime = date.ToString("t", _localizationService.CurrentCulture); + + // Act + var result = _localizationService.LocalizeDate(date); + + // Assert + result.Should().Be($"{expectedDate}, {expectedTime}"); + } + } +} \ No newline at end of file diff --git a/tests/SecureFolderFS.Tests/VaultTests/MigrationTests.cs b/tests/SecureFolderFS.Tests/VaultTests/MigrationTests.cs index b4d70ada4..d214df21b 100644 --- a/tests/SecureFolderFS.Tests/VaultTests/MigrationTests.cs +++ b/tests/SecureFolderFS.Tests/VaultTests/MigrationTests.cs @@ -43,7 +43,8 @@ public async Task Create_V2Vault_MigrateTo_V3Vault_NoThrow() // Act var migrator = await service.GetMigratorAsync(v2VaultFolder); - var keySequence = new KeySequence() { new DisposablePassword(MockVaultHelpers.VAULT_PASSWORD) }; + var keySequence = new KeySequence(); + keySequence.Add(new DisposablePassword(MockVaultHelpers.VAULT_PASSWORD)); var contract = await migrator.UnlockAsync(keySequence); await migrator.MigrateAsync(contract, new());