Skip to content

Commit 29c32d8

Browse files
committed
Normalize Base4K names on the INameCrypt level
1 parent 6659e88 commit 29c32d8

7 files changed

Lines changed: 48 additions & 41 deletions

File tree

src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/BaseNameCrypt.cs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ namespace SecureFolderFS.Core.Cryptography.NameCrypt
99
/// <inheritdoc cref="INameCrypt"/>
1010
internal abstract class BaseNameCrypt : INameCrypt
1111
{
12+
protected const NormalizationForm NORMALIZATION = NormalizationForm.FormC;
1213
protected readonly string fileNameEncodingId;
1314

1415
protected BaseNameCrypt(string fileNameEncodingId)
@@ -40,30 +41,50 @@ public virtual string EncryptName(ReadOnlySpan<char> plaintextName, ReadOnlySpan
4041
}
4142

4243
/// <inheritdoc/>
44+
[SkipLocalsInit]
4345
public virtual string? DecryptName(ReadOnlySpan<char> ciphertextName, ReadOnlySpan<byte> directoryId)
4446
{
4547
try
48+
{
49+
if (!ciphertextName.IsNormalized(NORMALIZATION))
50+
{
51+
var normalizedLength = ciphertextName.GetNormalizedLength(NORMALIZATION);
52+
var destination = normalizedLength < 256 ? stackalloc char[normalizedLength] : new char[normalizedLength];
53+
54+
// Try to normalize
55+
if (!ciphertextName.TryNormalize(destination, out var written, NORMALIZATION))
56+
return null;
57+
58+
// Decode
59+
return Decode(destination.Slice(0, written), directoryId);
60+
}
61+
62+
// Skip normalization and decode directly
63+
return Decode(ciphertextName, directoryId);
64+
}
65+
catch (Exception)
66+
{
67+
return null;
68+
}
69+
70+
string? Decode(ReadOnlySpan<char> name, ReadOnlySpan<byte> associatedData)
4671
{
4772
// Decode buffer
48-
var ciphertextNameBuffer = fileNameEncodingId switch
73+
var decoded = fileNameEncodingId switch
4974
{
50-
Constants.CipherId.ENCODING_BASE64URL => Base64Url.DecodeFromChars(ciphertextName),
51-
Constants.CipherId.ENCODING_BASE4K => Base4K.DecodeChainToNewBuffer(ciphertextName),
75+
Constants.CipherId.ENCODING_BASE64URL => Base64Url.DecodeFromChars(name),
76+
Constants.CipherId.ENCODING_BASE4K => Base4K.DecodeChainToNewBuffer(name),
5277
_ => throw new ArgumentOutOfRangeException(nameof(fileNameEncodingId))
5378
};
5479

5580
// Decrypt
56-
var plaintextNameBuffer = DecryptFileName(ciphertextNameBuffer, directoryId);
81+
var plaintextNameBuffer = DecryptFileName(decoded, associatedData);
5782
if (plaintextNameBuffer is null)
5883
return null;
5984

6085
// Get string from plaintext buffer
6186
return Encoding.UTF8.GetString(plaintextNameBuffer);
6287
}
63-
catch (Exception)
64-
{
65-
return null;
66-
}
6788
}
6889

6990
protected abstract byte[] EncryptFileName(ReadOnlySpan<byte> plaintextFileNameBuffer, ReadOnlySpan<byte> directoryId);

src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.cs

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,34 +21,23 @@ public static byte[] AllocateDirectoryId(Security security, string? path = null)
2121
return new byte[Constants.DIRECTORY_ID_SIZE];
2222
}
2323

24+
/// <summary>
25+
/// Removes the ciphertext file extension from the specified filename if it exists.
26+
/// This method ensures that the extension is stripped manually to avoid issues with
27+
/// path parsers that could misinterpret characters in the filename.
28+
/// </summary>
29+
/// <param name="ciphertextName">The filename with an optional ciphertext extension.</param>
30+
/// <returns>
31+
/// A <see cref="ReadOnlySpan{T}"/> representing the filename without the ciphertext extension.</returns>
2432
public static ReadOnlySpan<char> RemoveCiphertextExtension(string ciphertextName)
2533
{
2634
// Do NOT use Path.GetFileNameWithoutExtension - after APFS NFD-decomposes Base4K
2735
// codepoints, the string may contain spurious dot-like characters that confuse the
2836
// path parser, causing it to truncate mid-ciphertext.
2937
// Strip the known extension manually instead.
30-
var nameWithoutExtension = ciphertextName.EndsWith(Constants.Names.ENCRYPTED_FILE_EXTENSION, StringComparison.Ordinal)
38+
return ciphertextName.EndsWith(Constants.Names.ENCRYPTED_FILE_EXTENSION, StringComparison.Ordinal)
3139
? ciphertextName.AsSpan(0, ciphertextName.Length - Constants.Names.ENCRYPTED_FILE_EXTENSION.Length)
3240
: ciphertextName.AsSpan();
33-
34-
// Only normalize if needed. APFS layers NFD-decompose Base4K codepoints,
35-
// but Dokany/WinFsp deliver names in a form where NFC recomposition breaks lookup.
36-
if (OperatingSystem.IsIOS() || OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst())
37-
{
38-
// IsNormalized() lets the string itself determine whether normalization is safe.
39-
// TODO: Fix normalization in Vault V4
40-
if (!nameWithoutExtension.IsNormalized(NormalizationForm.FormC))
41-
{
42-
var normalizedLength = nameWithoutExtension.GetNormalizedLength(NormalizationForm.FormC);
43-
var normalized = new char[normalizedLength];
44-
45-
// NFC-normalize before decoding
46-
if (nameWithoutExtension.TryNormalize(normalized, out var written, NormalizationForm.FormC))
47-
return normalized.AsSpan(0, written);
48-
}
49-
}
50-
51-
return nameWithoutExtension;
5241
}
5342
}
5443
}

src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ public async Task<IChildFolder> CreateFolderAsync(string name, bool overwrite =
125125
var path = Path.Combine(Id, name);
126126
NSFileAttributes? attributes = null;
127127

128+
var isDirectory = false;
129+
if (overwrite && NSFileManager.DefaultManager.FileExists(path, ref isDirectory))
130+
{
131+
if (!isDirectory)
132+
throw new InvalidOperationException("Tried to overwrite an existing file with a directory.");
133+
134+
NSFileManager.DefaultManager.Remove(new NSUrl(path, true), out _);
135+
}
136+
128137
if (NSFileManager.DefaultManager.CreateDirectory(path, false, attributes, out var error))
129138
return new IOSFolder(new NSUrl(path, true), this, permissionRoot);
130139

src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<vc:DateTimeToStringConverter x:Key="DateTimeToStringConverter" />
1818
<vc:ThumbnailToAspectConverter x:Key="ThumbnailToAspectConverter" />
1919
<vc:SeverityHealthIconConverter x:Key="SeverityHealthIconConverter" />
20+
<vc:ExpandedStateToIconConverter x:Key="ExpandedStateToIconConverter" />
2021
<vc:StringInterpolationConverter x:Key="StringInterpolationConverter" />
2122

2223
</ResourceDictionary>

src/Platforms/SecureFolderFS.Maui/UserControls/ErrorControl.xaml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,8 @@
55
xmlns:l="using:SecureFolderFS.Maui.Localization"
66
xmlns:mi="http://www.aathifmahir.com/dotnet/2022/maui/icons"
77
xmlns:mtk="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
8-
xmlns:vc="clr-namespace:SecureFolderFS.Maui.ValueConverters"
98
x:Name="ThisControl">
109

11-
<ContentView.Resources>
12-
<vc:ExpandedStateToIconConverter x:Key="ExpandedStateToIconConverter" />
13-
</ContentView.Resources>
14-
1510
<VerticalStackLayout Spacing="4">
1611
<Label
1712
FontSize="17"

src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/CredentialsWizardPage.xaml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
xmlns:uc="clr-namespace:SecureFolderFS.Maui.UserControls"
1111
xmlns:ucc="clr-namespace:SecureFolderFS.Maui.UserControls.Common"
1212
xmlns:uco="clr-namespace:SecureFolderFS.Maui.UserControls.Options"
13-
xmlns:vc="clr-namespace:SecureFolderFS.Maui.ValueConverters"
1413
xmlns:vm="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls.Authentication;assembly=SecureFolderFS.Sdk"
1514
xmlns:vm2="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls.Components;assembly=SecureFolderFS.Sdk"
1615
x:Name="ThisPage"
@@ -23,10 +22,6 @@
2322
PrimaryEnabled="{Binding ViewModel.CanContinue, Mode=OneWay}"
2423
PrimaryText="{Binding OverlayViewModel.PrimaryText, Mode=OneWay}">
2524

26-
<md:BaseModalPage.Resources>
27-
<vc:ExpandedStateToIconConverter x:Key="ExpandedStateToIconConverter" />
28-
</md:BaseModalPage.Resources>
29-
3025
<md:BaseModalPage.ModalContent>
3126
<VerticalStackLayout Padding="16,0" Spacing="32">
3227
<VerticalStackLayout Spacing="16">

src/Platforms/SecureFolderFS.Maui/Views/Vault/HealthPage.xaml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,12 @@
1010
xmlns:ts="clr-namespace:SecureFolderFS.Maui.TemplateSelectors"
1111
xmlns:uc="clr-namespace:SecureFolderFS.Maui.UserControls"
1212
xmlns:uco="clr-namespace:SecureFolderFS.Maui.UserControls.Options"
13-
xmlns:vc="clr-namespace:SecureFolderFS.Maui.ValueConverters"
1413
xmlns:vm="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls.Widgets.Health;assembly=SecureFolderFS.Sdk"
1514
xmlns:vm2="clr-namespace:SecureFolderFS.UI.ViewModels.Health;assembly=SecureFolderFS.UI"
1615
Title="{l:ResourceString Rid=HealthReport}"
1716
x:DataType="local:HealthPage">
1817

1918
<ContentPage.Resources>
20-
<vc:ExpandedStateToIconConverter x:Key="ExpandedStateToIconConverter" />
21-
2219
<!-- Directory Issue -->
2320
<DataTemplate x:Key="DirectoryIssueTemplate" x:DataType="vm2:HealthDirectoryIssueViewModel">
2421
<mtk:Expander x:Name="IssueExpander">

0 commit comments

Comments
 (0)