Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fc8ce0a
Enable Vault V4
d2dyno1 Mar 22, 2026
ea1870a
Merge branch 'master' into f_vault4
d2dyno1 Mar 28, 2026
1a1602c
Merge branch 'master' into f_vault4
d2dyno1 Mar 31, 2026
b83a841
Merge branch 'master' into f_vault4
d2dyno1 Mar 31, 2026
274f7ac
Merge branch 'master' into f_vault4
d2dyno1 Apr 30, 2026
fe362ca
Apply code review
d2dyno1 Apr 30, 2026
22db7b2
Merge branch 'master' into f_vault4
d2dyno1 Apr 30, 2026
c85ef29
Update MainWindowRootControl.xaml
d2dyno1 Apr 30, 2026
6659e88
Merge branch 'master' into f_vault4
d2dyno1 May 1, 2026
29c32d8
Normalize Base4K names on the INameCrypt level
d2dyno1 May 5, 2026
15bbecf
Switched name encoding to more reliable Secomba Base4K implementation
d2dyno1 May 5, 2026
e9d666c
Normalize on EncryptName as well
d2dyno1 May 6, 2026
234fd63
Merge branch 'master' into f_vault4
d2dyno1 May 7, 2026
fcfc92c
Added the option to restore corrupted vault config files
d2dyno1 May 15, 2026
2105964
Disable App Platform authentication
d2dyno1 May 17, 2026
0c21e4f
Enable credential complementation (#124)
d2dyno1 May 19, 2026
ef029bb
Merge branch 'master' into f_vault4
d2dyno1 May 20, 2026
fdf95a4
Implemented file name shortening (#129)
d2dyno1 May 26, 2026
623997f
Detect faulty normalization of names in vault health
d2dyno1 May 26, 2026
3fe21f1
Apply code review
d2dyno1 May 27, 2026
fa0b278
Update CredentialsWizardPage.xaml
d2dyno1 May 27, 2026
c43b796
Moved old vault data models to Core.Migration
d2dyno1 May 27, 2026
502b662
Merge branch 'master' into f_vault4
d2dyno1 Jul 6, 2026
1e805db
Update BaseFuseCallbacks.cs
d2dyno1 Jul 6, 2026
f40a32f
Update Resources.resx
d2dyno1 Jul 6, 2026
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;
}
}
}
8 changes: 4 additions & 4 deletions src/Core/SecureFolderFS.Core.Cryptography/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ public static class KeyTraits
public const int DEK_KEY_LENGTH = 32;
public const int MAC_KEY_LENGTH = 32;
public const int ARGON2_KEK_LENGTH = 32;
public const int CHALLENGE_KEY_PART_LENGTH_32 = 32;
public const int CHALLENGE_KEY_PART_LENGTH_64 = 64;
public const int CHALLENGE_KEY_PART_LENGTH_128 = 128;
public const int ECIES_SHA256_AESGCM_STDX963_KEY_LENGTH = CHALLENGE_KEY_PART_LENGTH_32;
public const int KEY_PART_LENGTH_32 = 32;
public const int KEY_PART_LENGTH_64 = 64;
public const int KEY_PART_LENGTH_128 = 128;
public const int ECIES_SHA256_AESGCM_STDX963_KEY_LENGTH = KEY_PART_LENGTH_32;
public const int HMAC_SHA1_HASH_LENGTH = 20;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace SecureFolderFS.Core.Cryptography.Helpers
{
public static class CryptHelpers
{
public static IKeyBytes GenerateChallenge(string vaultId, int challengeSize = Constants.KeyTraits.CHALLENGE_KEY_PART_LENGTH_128)
public static IKeyBytes GenerateChallenge(string vaultId, int challengeSize = Constants.KeyTraits.KEY_PART_LENGTH_128)
{
var encodedVaultIdLength = Encoding.ASCII.GetByteCount(vaultId);
var challenge = new byte[challengeSize + encodedVaultIdLength];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@
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 readonly string fileNameEncodingId;
protected const NormalizationForm NORMALIZATION = NormalizationForm.FormC;

/// <inheritdoc/>
public virtual string EncodingId { get; }

protected BaseNameCrypt(string fileNameEncodingId)
{
this.fileNameEncodingId = fileNameEncodingId;
EncodingId = fileNameEncodingId;
}

/// <inheritdoc/>
Expand All @@ -31,39 +34,59 @@ public virtual string EncryptName(ReadOnlySpan<char> plaintextName, ReadOnlySpan
var ciphertextNameBuffer = EncryptFileName(bytes.Slice(0, count), directoryId);

// Encode string
return fileNameEncodingId switch
return EncodingId switch
{
Constants.CipherId.ENCODING_BASE64URL => Base64Url.EncodeToString(ciphertextNameBuffer),
Constants.CipherId.ENCODING_BASE4K => Base4K.EncodeChainToString(ciphertextNameBuffer),
_ => throw new ArgumentOutOfRangeException(nameof(fileNameEncodingId))
Constants.CipherId.ENCODING_BASE4K => SecombaBase4K.Encode(ciphertextNameBuffer).Normalize(NORMALIZATION),
_ => throw new ArgumentOutOfRangeException(nameof(EncodingId))
};
}

/// <inheritdoc/>
[SkipLocalsInit]
public virtual string? DecryptName(ReadOnlySpan<char> ciphertextName, ReadOnlySpan<byte> directoryId)
{
try
{
if (EncodingId == 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 = EncodingId switch
{
Constants.CipherId.ENCODING_BASE64URL => Base64Url.DecodeFromChars(ciphertextName),
Constants.CipherId.ENCODING_BASE4K => Base4K.DecodeChainToNewBuffer(ciphertextName),
_ => throw new ArgumentOutOfRangeException(nameof(fileNameEncodingId))
Constants.CipherId.ENCODING_BASE64URL => Base64Url.DecodeFromChars(name),
Constants.CipherId.ENCODING_BASE4K => SecombaBase4K.Decode(name),
_ => throw new ArgumentOutOfRangeException(nameof(EncodingId))
};

// 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
Expand Up @@ -7,6 +7,11 @@ namespace SecureFolderFS.Core.Cryptography.NameCrypt
/// </summary>
public interface INameCrypt : IDisposable
{
/// <summary>
/// Gets the encoding identifier.
/// </summary>
string EncodingId { get; }

/// <summary>
/// Encrypts the <paramref name="plaintextName"/> using associated <paramref name="directoryId"/>.
/// </summary>
Expand Down
Loading
Loading