Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions src/Core/SecureFolderFS.Core.Cryptography/Cipher/SecombaBase4K.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
// Some parts of the following code were used from Secomba/Base4K on the MIT License basis.
// See the associated license file for more information.

using System;
using System.Buffers;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;

namespace SecureFolderFS.Core.Cryptography.Cipher
{
public enum Base4KVersion
{
V1,
V2
}

public static class SecombaBase4K
{
// Base addresses for mapping regions
private const int BASE_FLAG_START = 0x04000;
private const int BASE1_START = 0x06000;
private const int BASE1_START_LEGACY = 0x05000;

// Sizes of each mapping region
private const int BASE_FLAG_SIZE = 0x100;
private const int BASE1_SIZE = 0x01000;

private static readonly UTF8Encoding Utf8Encoding = new UTF8Encoding(true, true);

/// <summary>
/// Encodes the specified raw bytes as a Base4K string, mapping each group of bits
/// to Unicode characters in a specific range, suitable for use as file names.
/// </summary>
/// <param name="raw">The raw bytes to encode.</param>
/// <param name="version">The version of Base4K encoding to use. Defaults to <see cref="Base4KVersion.V2"/>.</param>
/// <returns>A Base4K-encoded string representation of the input bytes.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="raw"/> is empty or too short to encode.</exception>
[SkipLocalsInit]
public static string Encode(ReadOnlySpan<byte> raw, Base4KVersion version = Base4KVersion.V2)
{
if (raw.Length <= 1)
throw new ArgumentException("Input must be at least 2 bytes long.", nameof(raw));

var maxByteCount = (raw.Length + 1) * 3;
var rentedBuffer = ArrayPool<byte>.Shared.Rent(maxByteCount);
try
{
var buffer = rentedBuffer.AsSpan();
var bufferPos = 0;
Span<byte> utf8Buffer = stackalloc byte[4];
int offset;

for (var i = 0; i < raw.Length * 2 - 2; i += 3)
{
offset = i % 2 == 0
? ((raw[i / 2] << 4) | ((raw[i / 2 + 1] >> 4) & 0x0f)) & 0x0fff
: ((raw[i / 2] << 8) | (raw[i / 2 + 1] & 0xff)) & 0x0fff;

offset += version == Base4KVersion.V1 ? BASE1_START_LEGACY : BASE1_START;

var written = ToUtf8(offset, utf8Buffer);
utf8Buffer.Slice(0, written).CopyTo(buffer.Slice(bufferPos));
bufferPos += written;
}

if ((raw.Length * 2) % 3 == 2)
{
offset = (raw[^1] & 0xff) + BASE_FLAG_START;
var written = ToUtf8(offset, utf8Buffer);
utf8Buffer.Slice(0, written).CopyTo(buffer.Slice(bufferPos));
bufferPos += written;
}
else if ((raw.Length * 2) % 3 == 1)
{
offset = (raw[^1] & 0x0f) + BASE_FLAG_START;
var written = ToUtf8(offset, utf8Buffer);
utf8Buffer.Slice(0, written).CopyTo(buffer.Slice(bufferPos));
bufferPos += written;
}

return Utf8Encoding.GetString(buffer.Slice(0, bufferPos));
}
finally
{
ArrayPool<byte>.Shared.Return(rentedBuffer);
}
}

/// <summary>
/// Decodes a Base4K-encoded string back to the original raw bytes.
/// Attempts decoding with both V2 and V1 (legacy) base addresses automatically.
/// </summary>
/// <param name="encoded">The Base4K-encoded string to decode.</param>
/// <returns>The decoded bytes, or <see langword="null"/> if decoding failed due to invalid or malformed input.</returns>
public static byte[]? Decode(ReadOnlySpan<char> encoded)
{
return DecodeInternal(encoded, BASE1_START) ?? DecodeInternal(encoded, BASE1_START_LEGACY);
}

private static byte[]? DecodeInternal(ReadOnlySpan<char> encoded, int base1Start)
{
var byteCount = Utf8Encoding.GetByteCount(encoded);
var encBytes = new byte[byteCount];
var written = Utf8Encoding.GetBytes(encoded, encBytes);

using var memoryStream = new MemoryStream();
var rentedCollector = ArrayPool<int>.Shared.Rent(written / 3 + 1);
var collectorCount = 0;
try
{
for (var i = 0; i < written;)
{
int nrOfBytes;
if ((encBytes[i] & 0x80) == 0)
{
// 1 byte
nrOfBytes = 1;
}
else if ((encBytes[i] & 0x40) == 0)
{
// Continuation byte — invalid as a leading byte
return null;
}
else if ((encBytes[i] & 0x20) == 0)
{
// 2 bytes
nrOfBytes = 2;
}
else if ((encBytes[i] & 0x10) == 0)
{
// 3 bytes
nrOfBytes = 3;
}
else if ((encBytes[i] & 0x08) == 0)
{
// 4 bytes
nrOfBytes = 4;
}
else
{
// Invalid leading byte
return null;
}

var code = ToCode(encBytes, i, nrOfBytes);
i += nrOfBytes;

if (!(code >= base1Start && code < base1Start + BASE1_SIZE))
{
if (i < written || !(code >= BASE_FLAG_START && code < BASE_FLAG_START + BASE_FLAG_SIZE))
return null;
}

rentedCollector[collectorCount++] = code;
}

for (var i = 0; i < collectorCount; i++)
{
if (rentedCollector[i] >= base1Start)
rentedCollector[i] -= base1Start;
else
{
rentedCollector[i] -= BASE_FLAG_START;
if (i % 2 == 0)
memoryStream.WriteByte((byte)rentedCollector[i]);
else
memoryStream.WriteByte((byte)(((rentedCollector[i - 1] << 4) | ((rentedCollector[i] & 0x0f)) & 0xff)));

break;
}

if (i % 2 == 0)
memoryStream.WriteByte((byte)(rentedCollector[i] >> 4));
else
{
memoryStream.WriteByte((byte)(((rentedCollector[i - 1] << 4) | ((rentedCollector[i] & 0x0f00) >> 8)) & 0xff));
memoryStream.WriteByte((byte)(rentedCollector[i] & 0xff));
}
}
}
finally
{
ArrayPool<int>.Shared.Return(rentedCollector);
}

return memoryStream.ToArray();
}

private static int ToUtf8(int code, Span<byte> destination)
{
switch (code)
{
case > 0xffff:
{
destination[0] = (byte)(0xf0 | ((code >> 18) & 0x07));
destination[1] = (byte)(0x80 | ((code >> 12) & 0x3f));
destination[2] = (byte)(0x80 | ((code >> 6) & 0x3f));
destination[3] = (byte)(0x80 | (code & 0x3f));
return 4;
}

case > 0x7ff:
{
destination[0] = (byte)(0xe0 | ((code >> 12) & 0x0f));
destination[1] = (byte)(0x80 | ((code >> 6) & 0x3f));
destination[2] = (byte)(0x80 | (code & 0x3f));
return 3;
}

case > 0x7f:
{
destination[0] = (byte)(0xc0 | ((code >> 6) & 0x1f));
destination[1] = (byte)(0x80 | (code & 0x3f));
return 2;
}

default:
{
destination[0] = (byte)(code & 0x7f);
return 1;
}
}
}

private static int ToCode(ReadOnlySpan<byte> utf8Char, int offset, int length)
{
var result = 0;
switch (length)
{
case 1:
{
result |= utf8Char[offset];
break;
}

case 2:
{
result |= (utf8Char[offset + 0] & 0x1f) << 6;
result |= (utf8Char[offset + 1] & 0x3f);
break;
}

case 3:
{
result |= (utf8Char[offset + 0] & 0x0f) << 12;
result |= (utf8Char[offset + 1] & 0x3f) << 6;
result |= (utf8Char[offset + 2] & 0x3f);
break;
}

case 4:
{
result |= (utf8Char[offset + 0] & 0x07) << 18;
result |= (utf8Char[offset + 1] & 0x3f) << 12;
result |= (utf8Char[offset + 2] & 0x3f) << 6;
result |= (utf8Char[offset + 3] & 0x3f);
break;
}
}

return result;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
using System.Buffers.Text;
using System.Runtime.CompilerServices;
using System.Text;
using Lex4K;
using SecureFolderFS.Core.Cryptography.Cipher;

namespace SecureFolderFS.Core.Cryptography.NameCrypt
{
/// <inheritdoc cref="INameCrypt"/>
internal abstract class BaseNameCrypt : INameCrypt
{
protected const NormalizationForm NORMALIZATION = NormalizationForm.FormC;
protected readonly string fileNameEncodingId;

protected BaseNameCrypt(string fileNameEncodingId)
Expand All @@ -34,36 +35,56 @@ public virtual string EncryptName(ReadOnlySpan<char> plaintextName, ReadOnlySpan
return fileNameEncodingId switch
{
Constants.CipherId.ENCODING_BASE64URL => Base64Url.EncodeToString(ciphertextNameBuffer),
Constants.CipherId.ENCODING_BASE4K => Base4K.EncodeChainToString(ciphertextNameBuffer),
Constants.CipherId.ENCODING_BASE4K => SecombaBase4K.Encode(ciphertextNameBuffer).Normalize(NORMALIZATION),
_ => throw new ArgumentOutOfRangeException(nameof(fileNameEncodingId))
};
}

/// <inheritdoc/>
[SkipLocalsInit]
public virtual string? DecryptName(ReadOnlySpan<char> ciphertextName, ReadOnlySpan<byte> directoryId)
{
try
{
if (fileNameEncodingId == Constants.CipherId.ENCODING_BASE4K && !ciphertextName.IsNormalized(NORMALIZATION))
{
var normalizedLength = ciphertextName.GetNormalizedLength(NORMALIZATION);
var destination = normalizedLength < 256 ? stackalloc char[normalizedLength] : new char[normalizedLength];

// Try to normalize
if (!ciphertextName.TryNormalize(destination, out var written, NORMALIZATION))
return null;

// Decode
return Decode(destination.Slice(0, written), directoryId);
}

// Skip normalization and decode directly
return Decode(ciphertextName, directoryId);
}
catch (Exception)
{
return null;
}

string? Decode(ReadOnlySpan<char> name, ReadOnlySpan<byte> associatedData)
{
// Decode buffer
var ciphertextNameBuffer = fileNameEncodingId switch
var decoded = fileNameEncodingId switch
{
Constants.CipherId.ENCODING_BASE64URL => Base64Url.DecodeFromChars(ciphertextName),
Constants.CipherId.ENCODING_BASE4K => Base4K.DecodeChainToNewBuffer(ciphertextName),
Constants.CipherId.ENCODING_BASE64URL => Base64Url.DecodeFromChars(name),
Constants.CipherId.ENCODING_BASE4K => SecombaBase4K.Decode(name),
_ => throw new ArgumentOutOfRangeException(nameof(fileNameEncodingId))
};

// Decrypt
var plaintextNameBuffer = DecryptFileName(ciphertextNameBuffer, directoryId);
var plaintextNameBuffer = DecryptFileName(decoded, associatedData);
if (plaintextNameBuffer is null)
return null;

// Get string from plaintext buffer
return Encoding.UTF8.GetString(plaintextNameBuffer);
}
catch (Exception)
{
return null;
}
}

protected abstract byte[] EncryptFileName(ReadOnlySpan<byte> plaintextFileNameBuffer, ReadOnlySpan<byte> directoryId);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using System.IO;
using System.Text;
using SecureFolderFS.Core.Cryptography;

namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract
Expand All @@ -21,34 +20,23 @@ public static byte[] AllocateDirectoryId(Security security, string? path = null)
return new byte[Constants.DIRECTORY_ID_SIZE];
}

/// <summary>
/// Removes the ciphertext file extension from the specified filename if it exists.
/// This method ensures that the extension is stripped manually to avoid issues with
/// path parsers that could misinterpret characters in the filename.
/// </summary>
/// <param name="ciphertextName">The filename with an optional ciphertext extension.</param>
/// <returns>
/// A <see cref="ReadOnlySpan{T}"/> representing the filename without the ciphertext extension.</returns>
public static ReadOnlySpan<char> RemoveCiphertextExtension(string ciphertextName)
{
// Do NOT use Path.GetFileNameWithoutExtension - after APFS NFD-decomposes Base4K
// codepoints, the string may contain spurious dot-like characters that confuse the
// path parser, causing it to truncate mid-ciphertext.
// Strip the known extension manually instead.
var nameWithoutExtension = ciphertextName.EndsWith(Constants.Names.ENCRYPTED_FILE_EXTENSION, StringComparison.Ordinal)
return ciphertextName.EndsWith(Constants.Names.ENCRYPTED_FILE_EXTENSION, StringComparison.Ordinal)
? ciphertextName.AsSpan(0, ciphertextName.Length - Constants.Names.ENCRYPTED_FILE_EXTENSION.Length)
: ciphertextName.AsSpan();

// Only normalize if needed. APFS layers NFD-decompose Base4K codepoints,
// but Dokany/WinFsp deliver names in a form where NFC recomposition breaks lookup.
if (OperatingSystem.IsIOS() || OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst())
{
// IsNormalized() lets the string itself determine whether normalization is safe.
// TODO: Fix normalization in Vault V4
if (!nameWithoutExtension.IsNormalized(NormalizationForm.FormC))
{
var normalizedLength = nameWithoutExtension.GetNormalizedLength(NormalizationForm.FormC);
var normalized = new char[normalizedLength];

// NFC-normalize before decoding
if (nameWithoutExtension.TryNormalize(normalized, out var written, NormalizationForm.FormC))
return normalized.AsSpan(0, written);
}
}

return nameWithoutExtension;
}
}
}
Loading
Loading