From b06767d479b91a700905d7638bffd392311f6e93 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:30:22 +0200 Subject: [PATCH 1/5] Backport HMAC-SHA verification for recycle bin items --- .../DataModels/RecycleBinItemDataModel.cs | 120 +++++++++++++++++- .../AbstractRecycleBinHelpers.Operational.cs | 6 +- .../AbstractRecycleBinHelpers.Shared.cs | 15 ++- .../NativeRecycleBinHelpers.Operational.cs | 16 ++- .../RecycleBinService.cs | 2 +- .../Storage/RecycleBinFolder.cs | 11 +- .../FileSystemTests/BaseFileSystemTests.cs | 11 +- .../FileSystemTests/RecycleBinTests.cs | 36 +++++- 8 files changed, 194 insertions(+), 23 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/DataModels/RecycleBinItemDataModel.cs b/src/Core/SecureFolderFS.Core.FileSystem/DataModels/RecycleBinItemDataModel.cs index e7751300c..9d43ae210 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/DataModels/RecycleBinItemDataModel.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/DataModels/RecycleBinItemDataModel.cs @@ -1,5 +1,9 @@ using System; +using System.Buffers.Binary; using System.IO; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; using System.Text.Json.Serialization; using SecureFolderFS.Core.Cryptography; @@ -38,14 +42,122 @@ public sealed record class RecycleBinItemDataModel [JsonPropertyName("size")] public required long? Size { get; init; } - // TODO: Add MAC key signing for tamper proofing + /// + /// Gets the HMAC-SHA256 tag binding this model's fields to the payload it describes. + /// + [JsonPropertyName("hmacsha256mac")] + public byte[]? PayloadMac { get; set; } public string? DecryptName(Security security) { - if (security.NameCrypt is null) - return Name; + var plaintextName = security.NameCrypt is null + ? Name + : security.NameCrypt.DecryptName(Path.GetFileNameWithoutExtension(Name), DirectoryId); + + // The name is later joined onto a folder path to reattach or restore the payload, and a name + // that is not a single path component would land that payload wherever the attacker chose (e.g., autostart dir). + // AES-SIV already yields separator-free tokens, but CipherId.NONE returns the name verbatim. + // Checked independently of the MAC, because the vault's own code writes these names and a correctly signed one must not escape either + if (plaintextName is null || !IsSingleNameComponent(plaintextName)) + return null; + + return plaintextName; + } + + /// + /// Returns a copy of this model carrying a over its fields and . + /// + /// The name of the payload in the recycle bin that this model describes. + /// The instance holding the vault's MAC key. + public RecycleBinItemDataModel WithMac(string itemName, Security security) + { + return this with { PayloadMac = ComputeMac(this, itemName, security) }; + } + + /// + /// Determines whether authenticates this model against . + /// + /// The name of the payload in the recycle bin that this model describes. + /// The instance holding the vault's MAC key. + /// + /// Every gate on the reattachment and restore paths - the Directory ID lineage match, the deletion + /// recency window, and the original parent path - reads its evidence from this model. Without the + /// tag those gates are authored by whoever can write into the vault's ciphertext directory. + /// + public bool VerifyMac(string itemName, Security security) + { + if (PayloadMac is not { Length: HMACSHA256.HashSizeInBytes }) + return false; + + return CryptographicOperations.FixedTimeEquals(ComputeMac(this, itemName, security), PayloadMac); + } + + // [SkipLocalsInit] - deliberately not used here. + private static byte[] ComputeMac(RecycleBinItemDataModel dataModel, string itemName, Security security) + { + var mac = new byte[HMACSHA256.HashSizeInBytes]; + security.KeyPair.MacKey.UseKey(macKey => + { + using var hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, macKey); + + // Every field is length-prefixed. Concatenating them raw would let bytes be shifted + // across the boundary between two adjacent attacker-influenced fields (Name + ParentId) to yield a different model with an identical MAC input. + // The payload's own name is bound too, so a valid configuration cannot be paired with a different payload file + AppendField(hmac, Encoding.UTF8.GetBytes(itemName), true); + AppendField(hmac, dataModel.Name is null ? default : Encoding.UTF8.GetBytes(dataModel.Name), dataModel.Name is not null); + AppendField(hmac, dataModel.ParentId is null ? default : Encoding.UTF8.GetBytes(dataModel.ParentId), dataModel.ParentId is not null); + AppendField(hmac, dataModel.DirectoryId, dataModel.DirectoryId is not null); + + // Ticks and Kind are bound exactly as serialized, so no timezone conversion sits + // between signing and verification + Span timestamp = stackalloc byte[sizeof(long) + 1]; + if (dataModel.DeletionTimestamp is { } deletionTimestamp) + { + BinaryPrimitives.WriteInt64LittleEndian(timestamp, deletionTimestamp.Ticks); + timestamp[sizeof(long)] = (byte)deletionTimestamp.Kind; + } + + AppendField(hmac, timestamp, dataModel.DeletionTimestamp is not null); + + Span size = stackalloc byte[sizeof(long)]; + if (dataModel.Size is { } sizeValue) + BinaryPrimitives.WriteInt64LittleEndian(size, sizeValue); + + AppendField(hmac, size, dataModel.Size is not null); - return security.NameCrypt.DecryptName(Path.GetFileNameWithoutExtension(Name), DirectoryId); + _ = hmac.GetHashAndReset(mac); + }); + + return mac; + + [SkipLocalsInit] + static void AppendField(IncrementalHash hmac, ReadOnlySpan value, bool isPresent) + { + // The presence flag keeps an absent field distinct from a present-but-empty one + Span header = stackalloc byte[sizeof(int) + 1]; + header[0] = isPresent ? (byte)1 : (byte)0; + BinaryPrimitives.WriteInt32LittleEndian(header[1..], isPresent ? value.Length : 0); + + hmac.AppendData(header); + if (isPresent) + hmac.AppendData(value); + } + } + + /// + /// Determines whether is a single path component that cannot escape its parent. + /// + /// The name to check. + /// + /// Both separators are checked regardless of the running platform. + /// + private static bool IsSingleNameComponent(string name) + { + return !string.IsNullOrEmpty(name) + && name is not ("." or "..") + && name.IndexOf('/') < 0 + && name.IndexOf('\\') < 0 + && !Path.IsPathRooted(name); } public string? DecryptParentId(Security security) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs index 7d2314936..3f5f226da 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs @@ -193,7 +193,7 @@ public static async Task DeleteOrRecycleAsync( // whereas a payload without a configuration file would be unrestorable. var guid = Guid.NewGuid().ToString(); var configurationFile = await modifiableRecycleBin.CreateFileAsync($"{guid}.json", false, cancellationToken); - await WriteItemDataModelAsync(configurationFile, dataModel, streamSerializer, cancellationToken); + await WriteItemDataModelAsync(configurationFile, dataModel, specifics.Security, streamSerializer, cancellationToken); IStorableChild movedItem; try @@ -218,7 +218,7 @@ public static async Task DeleteOrRecycleAsync( // The folded sizes were already part of the occupied total; only the // folder's own entry needs to account for its regained contents if (foldedSize > 0L) - await WriteItemDataModelAsync(configurationFile, dataModel with { Size = sizeHint + foldedSize }, streamSerializer, cancellationToken); + await WriteItemDataModelAsync(configurationFile, dataModel with { Size = sizeHint + foldedSize }, specifics.Security, streamSerializer, cancellationToken); } // Update occupied size @@ -287,7 +287,7 @@ internal static async Task FoldDescendantEntriesAsync( // A single unreadable entry must not abandon the remaining ones await SafetyHelpers.NoFailureAsync(async () => { - var dataModel = await GetItemDataModelAsync(configurationFile, recycleBin, streamSerializer, cancellationToken); + var dataModel = await GetItemDataModelAsync(configurationFile, recycleBin, specifics.Security, streamSerializer, cancellationToken); if (dataModel is not { Name: not null, ParentId: not null, DirectoryId: { Length: Constants.DIRECTORY_ID_SIZE } childDirectoryId }) return; 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 e83f5f2ed..32de42e00 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 @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; +using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.DataModels; using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Shared.ComponentModel; @@ -182,22 +183,25 @@ private static async Task GetFolderPlaintextSizeAsync(IFolder ciphertextFo /// /// Serializes into , truncating any previous content. /// - internal static Task WriteItemDataModelAsync(IFile configurationFile, RecycleBinItemDataModel dataModel, IAsyncSerializer streamSerializer, CancellationToken cancellationToken) + internal static Task WriteItemDataModelAsync(IFile configurationFile, RecycleBinItemDataModel dataModel, Security security, IAsyncSerializer streamSerializer, CancellationToken cancellationToken) { + // Sign on the way out so every write (including the size rewrite after folding) is covered + var signedDataModel = dataModel.WithMac(Path.GetFileNameWithoutExtension(configurationFile.Name), security); + return WithTransientIoRetryAsync(async () => { await using var configurationStream = await configurationFile.OpenWriteAsync(cancellationToken); if (configurationStream.CanSeek) configurationStream.SetLength(0L); - await using var serializedStream = await streamSerializer.SerializeAsync(dataModel, cancellationToken); + await using var serializedStream = await streamSerializer.SerializeAsync(signedDataModel, cancellationToken); await serializedStream.CopyToAsync(configurationStream, cancellationToken); await configurationStream.FlushAsync(cancellationToken); return null; }, cancellationToken); } - public static async Task GetItemDataModelAsync(IStorableChild item, IFolder recycleBin, IAsyncSerializer streamSerializer, CancellationToken cancellationToken = default) + public static async Task GetItemDataModelAsync(IStorableChild item, IFolder recycleBin, Security security, IAsyncSerializer streamSerializer, CancellationToken cancellationToken = default) { // Get the configuration file var configurationFile = !item.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) @@ -216,6 +220,11 @@ public static async Task GetItemDataModelAsync(IStorabl if (deserialized is not { ParentId: not null }) throw new FormatException("Could not deserialize recycle bin configuration file."); + // Reject anything this vault did not write. Callers treat a throw as a corrupt entry, which + // leaves the payload in place and permanently deletable rather than acting on forged metadata + if (!deserialized.VerifyMac(Path.GetFileNameWithoutExtension(configurationFile.Name), security)) + throw new UnauthorizedAccessException("The recycle bin configuration file is not authentic."); + return deserialized; } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs index 5beb09080..85ca93a53 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs @@ -1,6 +1,7 @@ using System; using System.IO; using OwlCore.Storage; +using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.DataModels; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; @@ -117,7 +118,7 @@ public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics sp var guid = Guid.NewGuid().ToString(); var destinationPath = Path.Combine(recycleBinPath, guid); var configurationPath = $"{destinationPath}.json"; - WriteItemDataModel(configurationPath, dataModel); + WriteItemDataModel(configurationPath, dataModel, specifics.Security); // The folder's original plaintext path must be captured before the move - it is // the key that previously recycled children are folded back in by @@ -149,7 +150,7 @@ public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics sp { // The folded sizes were already part of the occupied total; only the // folder's own entry needs to account for its regained contents - SafetyHelpers.NoFailure(() => WriteItemDataModel(configurationPath, dataModel with { Size = sizeHint + foldedSize })); + SafetyHelpers.NoFailure(() => WriteItemDataModel(configurationPath, dataModel with { Size = sizeHint + foldedSize }, specifics.Security)); } } @@ -212,10 +213,13 @@ static bool IsRecentlyCreated(string path) /// /// Serializes into the file at , truncating any previous content. /// - private static void WriteItemDataModel(string configurationPath, RecycleBinItemDataModel dataModel) + private static void WriteItemDataModel(string configurationPath, RecycleBinItemDataModel dataModel, Security security) { + // Sign on the way out so every write (including the size rewrite after folding) is covered + var signedDataModel = dataModel.WithMac(Path.GetFileNameWithoutExtension(configurationPath), security); + using var configurationStream = File.Create(configurationPath); - using var serializedStream = StreamSerializer.Instance.SerializeAsync(dataModel).ConfigureAwait(false).GetAwaiter().GetResult(); + using var serializedStream = StreamSerializer.Instance.SerializeAsync(signedDataModel).ConfigureAwait(false).GetAwaiter().GetResult(); serializedStream.CopyTo(configurationStream); configurationStream.Flush(); @@ -268,6 +272,10 @@ private static long FoldDescendantEntries(string recycleBinPath, string recycled if (!childDirectoryId.AsSpan().SequenceEqual(folderDirectoryId)) return; + // Check if the data model is authentic + if (!dataModel.VerifyMac(Path.GetFileNameWithoutExtension(configurationPath), specifics.Security)) + return; + // Recency check: don't silently pull in unrelated deletions from long ago if (dataModel.DeletionTimestamp is not { } deletionTimestamp || Math.Abs((DateTime.Now - deletionTimestamp).TotalMilliseconds) > Constants.RECYCLE_BIN_FOLD_WINDOW_MS) diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs index 7ac5f1b68..5b4395916 100644 --- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs +++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs @@ -122,7 +122,7 @@ public async Task RecalculateSizesAsync(IVfsRoot vfsRoot, CancellationToken canc // A single corrupt entry must not abandon the recalculation of the remaining ones try { - var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(configurationFile, recycleBin, StreamSerializer.Instance, cancellationToken); + var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(configurationFile, recycleBin, specifics.Security, StreamSerializer.Instance, cancellationToken); if (dataModel.Size is { } size and >= 0L) { totalSize += size; diff --git a/src/Platforms/SecureFolderFS.UI/Storage/RecycleBinFolder.cs b/src/Platforms/SecureFolderFS.UI/Storage/RecycleBinFolder.cs index 9b2d71223..8b8537d41 100644 --- a/src/Platforms/SecureFolderFS.UI/Storage/RecycleBinFolder.cs +++ b/src/Platforms/SecureFolderFS.UI/Storage/RecycleBinFolder.cs @@ -75,7 +75,7 @@ public async Task RestoreItemsAsync(IEnumerable items, IFolderPi try { - var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(item, _recycleBin, _serializer, cancellationToken); + var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(item, _recycleBin, _specifics.Security, _serializer, cancellationToken); var plaintextParentPath = SafetyHelpers.NoFailureResult(() => dataModel.DecryptParentId(_specifics.Security)); restoreQueue.Add((item, originalItem, plaintextParentPath)); } @@ -231,11 +231,10 @@ public async Task DeleteAsync(IStorableChild item, CancellationToken cancellatio RecycleBinItemDataModel? itemDataModel = null; if (configurationFile is not null) { + // Read through the verifying helper. Forging configuration must not be able to + // dictate the size subtracted from the occupied total. On failure the size below is measured from the payload instead itemDataModel = await SafetyHelpers.NoFailureAsync(async () => - { - await using var configurationStream = await configurationFile.OpenReadAsync(cancellationToken); - return await _serializer.DeserializeAsync(configurationStream, cancellationToken); - }); + await AbstractRecycleBinHelpers.GetItemDataModelAsync(item, _recycleBin, _specifics.Security, _serializer, cancellationToken)); } // Determine the size to subtract before the payload is gone @@ -278,7 +277,7 @@ private async Task MaterializeItemAsync(IStorableChild item, Ca { var recycleBinItem = await SafetyHelpers.NoFailureAsync(async () => { - var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(item, _recycleBin, StreamSerializer.Instance, cancellationToken); + var dataModel = await AbstractRecycleBinHelpers.GetItemDataModelAsync(item, _recycleBin, _specifics.Security, StreamSerializer.Instance, cancellationToken); if (dataModel.ParentId is null || dataModel.Name is null) return null; diff --git a/tests/SecureFolderFS.Tests/FileSystemTests/BaseFileSystemTests.cs b/tests/SecureFolderFS.Tests/FileSystemTests/BaseFileSystemTests.cs index a1a2a9ab6..580c1c0b6 100644 --- a/tests/SecureFolderFS.Tests/FileSystemTests/BaseFileSystemTests.cs +++ b/tests/SecureFolderFS.Tests/FileSystemTests/BaseFileSystemTests.cs @@ -1,4 +1,5 @@ -using SecureFolderFS.Sdk.Services; +using OwlCore.Storage; +using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared; using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; @@ -9,6 +10,12 @@ namespace SecureFolderFS.Tests.FileSystemTests { public abstract class BaseFileSystemTests { + /// + /// Gets the raw ciphertext content folder of the vault mounted by . + /// + /// Used by tests that tamper with the stored ciphertext directly. + protected IFolder? CiphertextContentFolder { get; private set; } + protected async Task MountVault(IFileSystemInfo fileSystem, MockVaultOptions? options, params (string, object)[] additionalOptions) { var (vaultFolder, recoveryKey) = await MockVaultHelpers.CreateVaultLatestAsync(options); @@ -33,6 +40,8 @@ protected async Task MountVault(IFileSystemInfo fileSystem, MockVaultO // Create the storage layer var contentFolder = await vaultFolder.GetFolderByNameAsync(vaultService.ContentFolderName); + CiphertextContentFolder = contentFolder; + return await fileSystem.MountAsync(contentFolder, unlockContract, fileSystemOptions); } } diff --git a/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs b/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs index c3d2187e2..99c23b57f 100644 --- a/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs +++ b/tests/SecureFolderFS.Tests/FileSystemTests/RecycleBinTests.cs @@ -1,4 +1,5 @@ -using FluentAssertions; +using System.Text.RegularExpressions; +using FluentAssertions; using NUnit.Framework; using OwlCore.Storage; using SecureFolderFS.Sdk.Services; @@ -165,6 +166,39 @@ public async Task Restore_File_WhenNameConflictExists_AppendsSuffix() rootItems.Select(x => x.Name).Should().Contain("CONFLICT (1)"); } + [Test] + public async Task TamperedConfiguration_IsRejected_AndItemSurfacesAsOrphaned() + { + ArgumentNullException.ThrowIfNull(_storageRoot); + ArgumentNullException.ThrowIfNull(_recycleBinService); + ArgumentNullException.ThrowIfNull(CiphertextContentFolder); + + // Arrange - recycle a file so a signed configuration file exists + var modifiableFolder = _storageRoot.PlaintextRoot as IModifiableFolder ?? throw new ArgumentException($"Folder is not {nameof(IModifiableFolder)}."); + var file = await modifiableFolder.CreateFileAsync("TAMPER"); + await modifiableFolder.DeleteAsync(file); + + var recycleBin = await _recycleBinService.GetRecycleBinAsync(_storageRoot); + (await recycleBin.GetItemsAsync().ToArrayAsyncImpl()).Single().Name.Should().Be("TAMPER"); + + // Act - rewrite the configuration the way an attacker with ciphertext write access would. + var binFolder = await CiphertextContentFolder.GetFolderByNameAsync(Core.FileSystem.Constants.Names.RECYCLE_BIN_NAME); + var configurationFile = await binFolder.GetItemsAsync(StorableType.File) + .FirstAsync(x => x.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) as IFile; + + configurationFile.Should().NotBeNull(); + var configuration = await configurationFile!.ReadTextAsync(); + var tampered = Regex.Replace(configuration, "\"deletionTimestamp\":\\s*\"[^\"]*\"", "\"deletionTimestamp\":\"2020-01-01T00:00:00.0000000+00:00\""); + tampered.Should().NotBe(configuration); + await configurationFile!.WriteTextAsync(tampered); + + // Assert - the entry no longer authenticates, so it is surfaced as an unnamed orphan + // (still permanently deletable) rather than acted upon + var afterTamper = await recycleBin.GetItemsAsync().ToArrayAsyncImpl(); + afterTamper.Should().HaveCount(1); + afterTamper.Single().Name.Should().NotBe("TAMPER"); + } + [Test] public async Task RecalculateSizes_Matches_TrackedOccupiedSize() { From 4bf5dac03c5e675712301850f9f5d5e0ca7cb0af Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:32:51 +0200 Subject: [PATCH 2/5] Fixed build --- .../Abstract/AbstractRecycleBinHelpers.Operational.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs index 3f5f226da..fcd04354a 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs @@ -30,7 +30,7 @@ public static partial class AbstractRecycleBinHelpers throw new DirectoryNotFoundException("Could not find recycle bin folder."); // Deserialize configuration - var deserialized = await GetItemDataModelAsync(recycleBinItem, recycleBin, streamSerializer, cancellationToken); + var deserialized = await GetItemDataModelAsync(recycleBinItem, recycleBin, specifics.Security, streamSerializer, cancellationToken); if (deserialized is not { ParentId: not null, Name: not null }) throw new FormatException("Could not deserialize recycle bin configuration file."); @@ -63,7 +63,7 @@ public static async Task RestoreAsync(IStorableChild recycleBinItem, IModifiable throw new UnauthorizedAccessException("The recycle bin is not modifiable."); // Deserialize configuration - var deserialized = await GetItemDataModelAsync(recycleBinItem, recycleBin, streamSerializer, cancellationToken); + var deserialized = await GetItemDataModelAsync(recycleBinItem, recycleBin, specifics.Security, streamSerializer, cancellationToken); if (deserialized is not { ParentId: not null, Name: not null }) throw new FormatException("Could not deserialize recycle bin configuration file."); From 7630dcc5451fe79c9eaa67d6e03666588ff71a60 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:14:01 +0200 Subject: [PATCH 3/5] Update onboarding strings --- .../SecureFolderFS.UI/Strings/en-US/Resources.resx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx index 11824f0dd..66345105c 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx @@ -924,7 +924,7 @@ You're in control - Quickly manage your vault from a top-down dashboard view. Modify credentials, browse files, and check the integrity of your files. + Everything you need in one place. Quickly browse your files, modify credentials, and monitor your vault's integrity from a centralized dashboard. Your data — secure, anywhere @@ -933,22 +933,22 @@ In addition to storing vaults on your device, you can also connect to other cloud storage providers. - Authenticate the way you want + Unlock your vaults your way - With our robust security, you choose how you access your files. Whether it's password, Windows Hello, or a key file — your data remains protected and readily available, tailored to your preferences. + With our robust security, you choose how you access your files. Whether it's a password, hardware security key, or biometrics — your data remains protected and readily available. - Your files, always protected + Strong encryption by design - Your files are encrypted using industry-standard AES-256 encryption. Even file and folder names are protected, ensuring that your sensitive documents remain completely private. + Every file is protected with AES-256-GCM encryption. Even file and folder names are encrypted to help keep your data truly private. Seamless virtual file system - Thanks to our seamless virtual file system engine, you can access your data worry-free without compromising speed or security. Your encrypted vault appears as a regular drive. + Your vault appears as a regular drive, allowing you to browse, edit, and use your favorite apps without compromising on performance or security. You're ready to go! From 40476ca24e92a48cc38e2194db472bac025581d0 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:26:10 +0200 Subject: [PATCH 4/5] Fixed pluralization --- .../Strings/en-US/Resources.resx | 28 +++++++++---------- .../Extensions/LocalizationExtensions.cs | 10 +++++-- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx index 66345105c..859f98ebd 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx @@ -1047,10 +1047,10 @@ Yesterday, {0} - {0:plural:Last week|{} weeks ago|{} weeks ago} + {0:plural:Last week|{} weeks ago} - {0:plural:{} day ago|{} days ago|{} days ago} + {0:plural:{} day ago|{} days ago} Vault is unlocked @@ -1191,13 +1191,13 @@ Clear selection - Copying {Total:choose(0):{Achieved} {Achieved:plural:item|items|items}|{Total:choose(1):{State}|{Achieved}/{Total} {Total:plural:item|items|items}}} + Copying {Total:choose(0):{Achieved} {Achieved:plural:item|items}|{Total:choose(1):{State}|{Achieved}/{Total} {Total:plural:item|items}}} - Moving {Total:choose(0):{Achieved} {Achieved:plural:item|items|items}|{Total:choose(1):{State}|{Achieved}/{Total} {Total:plural:item|items|items}}} + Moving {Total:choose(0):{Achieved} {Achieved:plural:item|items}|{Total:choose(1):{State}|{Achieved}/{Total} {Total:plural:item|items}}} - Deleting {Total:choose(0):{Achieved} {Achieved:plural:item|items|items}|{Total:choose(1):{State}|{Achieved}/{Total} {Total:plural:item|items|items}}} + Deleting {Total:choose(0):{Achieved} {Achieved:plural:item|items}|{Total:choose(1):{State}|{Achieved}/{Total} {Total:plural:item|items}}} Not enough space @@ -1206,7 +1206,7 @@ Deleting item(s) - Are you sure you want to permanently delete {0:plural:one item|{} items|{} items}? + Are you sure you want to permanently delete {0:plural:one item|{} items}? The deleted {0:plural:item exceeds|{} items exceed|{} items exceed} the available space in the recycle bin. Do you want to permanently delete {0:plural:this item|{} items|{} items} instead? @@ -1242,13 +1242,13 @@ Enable Device Link - {0:plural:One item|{} items|{} items} selected + {0:plural:One item|{} items} selected - {0:plural:{} element|{} elements|{} elements} + {0:plural:{} element|{} elements} - Found {0:plural:one issue|{} issues|{} issues} + Found {0:plural:one issue|{} issues} Available widgets @@ -1269,7 +1269,7 @@ This archive format is not supported for extraction - Extracting {Total:choose(0):{Achieved} {Achieved:plural:item|items|items}|{Total:choose(1):{State}|{Achieved}/{Total} {Total:plural:item|items|items}}} + Extracting {Total:choose(0):{Achieved} {Achieved:plural:item|items}|{Total:choose(1):{State}|{Achieved}/{Total} {Total:plural:item|items}}} Extracting... @@ -1359,7 +1359,7 @@ Collecting items ({0}) - Collected {0:plural:{} item|{} items|{} items} + Collected {0:plural:{} item|{} items} Scan completed @@ -1437,7 +1437,7 @@ Lock all - {0:plural:One vault is|{} vaults are|{} vaults are} unlocked + {0:plural:One vault is|{} vaults are} unlocked View in app @@ -1503,10 +1503,10 @@ Couldn't update the vault credentials - Couldn't delete {0:plural:one item|{} items|{} items} + Couldn't delete {0:plural:one item|{} items} - Couldn't restore {0:plural:one item|{} items|{} items} + Couldn't restore {0:plural:one item|{} items} Taken {0} out of {1} diff --git a/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs b/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs index a49446659..0ed6ee4b5 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs @@ -44,7 +44,13 @@ public static string ToLocalized(this string resourceKey, ILocalizationService? public static string ToLocalized(this string resourceKey, params object?[] interpolate) { var localized = ToLocalized(resourceKey); - return SafetyHelpers.NoFailureResult(() => Smart.Format(localized, interpolate)) ?? $"{{{resourceKey}}}"; + return SafetyHelpers.NoFailureResult(() => + { + if (_fallbackLocalizationService is null) + return $"{{{resourceKey}}}"; + + return Smart.Format(_fallbackLocalizationService.CurrentCulture, localized, interpolate); + }) ?? $"{{{resourceKey}}}"; } /// @@ -58,7 +64,7 @@ public static string ToLocalized(this string resourceKey, params object?[] inter public static string ToLocalized(this string resourceKey, ILocalizationService localizationService, params object?[] interpolate) { var localized = ToLocalized(resourceKey, localizationService); - return SafetyHelpers.NoFailureResult(() => Smart.Format(localized, interpolate)) ?? $"{{{resourceKey}}}"; + return SafetyHelpers.NoFailureResult(() => Smart.Format(localizationService.CurrentCulture, localized, interpolate)) ?? $"{{{resourceKey}}}"; } } } From cf1255af2b9813836793f99d1fa9502d5a22817c Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:51:40 +0200 Subject: [PATCH 5/5] Removed some left-over TODO comments --- .../SecureFolderFS.Core.Cryptography/Cipher/AesCtr256.cs | 4 ++-- .../Callbacks/BaseDokanyCallbacks.cs | 2 -- .../Validators/FolderValidator.cs | 6 +++--- .../SecureFolderFS.Uno/UserControls/GraphControl.xaml.cs | 3 +-- .../UserControls/InterfaceHost/MainAppHostControl.xaml | 1 - .../UserControls/Migration/MigratorV1_V2.xaml | 1 - .../SecureFolderFS.Sdk/AppModels/VaultCollectionModel.cs | 1 - 7 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr256.cs b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr256.cs index 1a757260c..b88bbb977 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr256.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr256.cs @@ -11,7 +11,7 @@ public static class AesCtr256 public static void Encrypt(ReadOnlySpan bytes, ReadOnlySpan key, ReadOnlySpan iv, Span result) { - ulong ulIv = BitConverter.ToUInt64(iv); // TODO: ulIv good here? + ulong ulIv = BitConverter.ToUInt64(iv); using var aesCtr = new AesCounterMode(ulIv, CTR_START); var transformEnc = aesCtr.CreateEncryptor(key.ToArray(), null); @@ -25,7 +25,7 @@ public static bool Decrypt(ReadOnlySpan bytes, ReadOnlySpan key, Rea { try { - ulong ulIv = BitConverter.ToUInt64(iv); // TODO: ulIv good here? + ulong ulIv = BitConverter.ToUInt64(iv); using var aesCtr = new AesCounterMode(ulIv, CTR_START); var transformDec = aesCtr.CreateDecryptor(key.ToArray(), null); diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs index 9a3283132..6f1474161 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs @@ -124,7 +124,6 @@ public virtual NtStatus GetVolumeInformation(out string volumeLabel, out FileSys /// public virtual NtStatus Mounted(string mountPoint, IDokanFileInfo info) { - _ = mountPoint; // TODO: Check if mountPoint is different and update the RootFolder (?) return Trace(DokanResult.Success, null, info); } @@ -358,7 +357,6 @@ public virtual unsafe NtStatus WriteFile(string fileName, IntPtr buffer, uint bu /// public abstract NtStatus SetFileSecurity(string fileName, FileSystemSecurity security, AccessControlSections sections, IDokanFileInfo info); - // TODO: Add checks for nullable in places where this function is called protected abstract string? GetCiphertextPath(string plaintextName); protected void CloseHandle(IDokanFileInfo info) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/FolderValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/FolderValidator.cs index 7455a0446..26934c1a4 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/FolderValidator.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/FolderValidator.cs @@ -32,12 +32,12 @@ public override async Task ValidateAsync(IFolder value, CancellationToken cancel return; // Check if Directory ID exists - var directoryIdFile = await value.GetFileByNameAsync(Core.FileSystem.Constants.Names.DIRECTORY_ID_FILENAME, cancellationToken).ConfigureAwait(false); + var directoryIdFile = await value.GetFileByNameAsync(Constants.Names.DIRECTORY_ID_FILENAME, cancellationToken).ConfigureAwait(false); // Check the size await using var stream = await directoryIdFile.OpenReadAsync(cancellationToken).ConfigureAwait(false); - if (stream.Length != Core.FileSystem.Constants.DIRECTORY_ID_SIZE) - throw new EndOfStreamException($"The Directory ID size is invalid. Expected: {Core.FileSystem.Constants.DIRECTORY_ID_SIZE}; Got: {stream.Length}."); + if (stream.Length != Constants.DIRECTORY_ID_SIZE) + throw new EndOfStreamException($"The Directory ID size is invalid. Expected: {Constants.DIRECTORY_ID_SIZE}; Got: {stream.Length}."); } catch (Exception ex) { diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/GraphControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/GraphControl.xaml.cs index aff8b93a6..01604bb70 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/GraphControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/GraphControl.xaml.cs @@ -42,7 +42,6 @@ private async void RootButton_Loaded(object sender, RoutedEventArgs e) private async void Chart_Loaded(object sender, RoutedEventArgs e) { // Workaround for the application freezing after unlocking at least 2 vaults - // TODO: Find the cause of the issue and fix it await Task.Delay(500); if (sender is not CartesianChart chart) @@ -63,7 +62,7 @@ private async void Chart_Loaded(object sender, RoutedEventArgs e) DataPadding = new(0.5f, 0), AnimationsSpeed = TimeSpan.FromMilliseconds(0), IsHoverable = false, - GeometrySize = 0d // TODO: Setting this to any value other than 0 causes a bug with jumping line series + GeometrySize = 0d // Setting this to any value other than 0 causes a bug with jumping line series } ]; chart.XAxes = diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml index 65e5fca93..b3f967e2a 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceHost/MainAppHostControl.xaml @@ -104,7 +104,6 @@ -