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
2 changes: 2 additions & 0 deletions src/Core/SecureFolderFS.Core.FileSystem/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public static class FileSystem
public static class Names
{
public const string ENCRYPTED_FILE_EXTENSION = ".sffs";
public const string SHORTENED_FILE_EXTENSION = ".sffsn";
public const string SIDECAR_FILE_EXTENSION = ".sffsi";
public const string DIRECTORY_ID_FILENAME = "dirid.iv";
public const string RECYCLE_BIN_NAME = "recycle_bin";
public const string RECYCLE_BIN_CONFIGURATION_FILENAME = "recycle_bin.cfg";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract
{
public static partial class AbstractPathHelpers
{
#region Encrypt Name Non-Materialized

/// <inheritdoc cref="EncryptNameAsync(string,OwlCore.Storage.IFolder,SecureFolderFS.Core.FileSystem.FileSystemSpecifics,System.Byte[],System.Threading.CancellationToken)"/>
public static async Task<string> EncryptNameAsync(string plaintextName, IFolder ciphertextParentFolder,
FileSystemSpecifics specifics, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -62,6 +64,81 @@ public static async Task<string> EncryptNameAsync(string plaintextName, IFolder
return security.NameCrypt.EncryptName(plaintextName, result ? directoryId : ReadOnlySpan<byte>.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
}

#endregion

#region Encrypt Name Materialized

/// <inheritdoc cref="EncryptNameForUseAsync(string,OwlCore.Storage.IFolder,SecureFolderFS.Core.FileSystem.FileSystemSpecifics,System.Byte[],System.Threading.CancellationToken)"/>
public static async Task<string> EncryptNameForUseAsync(string plaintextName, IFolder ciphertextParentFolder,
FileSystemSpecifics specifics, CancellationToken cancellationToken = default)
{
if (specifics.Security.NameCrypt is null)
return plaintextName;

var directoryId = AllocateDirectoryId(specifics.Security, plaintextName);
return await EncryptNameForUseAsync(plaintextName, ciphertextParentFolder, specifics, directoryId, cancellationToken);
}

/// <summary>
/// Encrypts the provided <paramref name="plaintextName"/> and materializes it.
/// </summary>
/// <param name="plaintextName">The name to encrypt.</param>
/// <param name="ciphertextParentFolder">The ciphertext parent folder.</param>
/// <param name="specifics">The <see cref="FileSystemSpecifics"/> instance associated with the item.</param>
/// <param name="expendableDirectoryId">A buffer of size <see cref="Constants.DIRECTORY_ID_SIZE"/> which will be used to hold the Directory ID data.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that cancels this action.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous operation. Value is an encrypted name with the appropriate file extension appended.</returns>
public static async Task<string> EncryptNameForUseAsync(string plaintextName, IFolder ciphertextParentFolder,
FileSystemSpecifics specifics, byte[]? expendableDirectoryId = null, CancellationToken cancellationToken = default)
{
if (specifics.Security.NameCrypt is null)
return plaintextName;

expendableDirectoryId ??= AllocateDirectoryId(specifics.Security, plaintextName);
var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, expendableDirectoryId, cancellationToken);

var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan<byte>.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
if (SHORTENING_THRESHOLD > 0 && encryptedName.Length >= SHORTENING_THRESHOLD)
{
var shortenedBase = ComputeShortenedNameBase(encryptedName);
await WriteSidecarAsync(ciphertextParentFolder, shortenedBase, encryptedName, cancellationToken);
return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION;
}

return encryptedName;
}

/// <summary>
/// Encrypts the provided <paramref name="plaintextName"/> and materializes it.
/// </summary>
/// <param name="plaintextName">The name to encrypt.</param>
/// <param name="ciphertextParentFolder">The ciphertext parent folder.</param>
/// <param name="contentFolder">The content folder.</param>
/// <param name="security">The <see cref="Security"/> instance associated with the item.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that cancels this action.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous operation. Value is an encrypted name with the appropriate file extension appended.</returns>
public static async Task<string> EncryptNameForUseAsync(string plaintextName, IFolder ciphertextParentFolder, IFolder contentFolder,
Security security, CancellationToken cancellationToken = default)
{
if (security.NameCrypt is null)
return plaintextName;

var directoryId = AllocateDirectoryId(security, plaintextName);
var result = await GetDirectoryIdAsync(ciphertextParentFolder, contentFolder, directoryId, cancellationToken);

var encryptedName = security.NameCrypt.EncryptName(plaintextName, result ? directoryId : ReadOnlySpan<byte>.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
if (SHORTENING_THRESHOLD > 0 && encryptedName.Length >= SHORTENING_THRESHOLD)
{
var shortenedBase = ComputeShortenedNameBase(encryptedName);
await WriteSidecarAsync(ciphertextParentFolder, shortenedBase, encryptedName, cancellationToken);
return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION;
}

return encryptedName;
}

#endregion

/// <summary>
/// Encrypts a plaintext name using the specified Directory ID and security parameters.
/// </summary>
Expand Down Expand Up @@ -103,8 +180,23 @@ public static string EncryptNewName(string plaintextName, byte[] newDirectoryId,
if (specifics.Security.NameCrypt is null)
return ciphertextName;

// Sidecar files are internal bookkeeping - they have no plaintext name
if (IsSidecarName(ciphertextName))
return null;

try
{
// Resolve shortened names to their full ciphertext name via the paired sidecar
if (ciphertextName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
{
var shortenedBase = RemoveShortenedExtension(ciphertextName).ToString();
var resolvedName = await ReadSidecarAsync(ciphertextParentFolder, shortenedBase, cancellationToken);
if (resolvedName is null)
return null;

ciphertextName = resolvedName;
}

expendableDirectoryId ??= AllocateDirectoryId(specifics.Security, ciphertextName);
var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, expendableDirectoryId, cancellationToken);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System;
using System.Buffers.Text;
using System.IO;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using OwlCore.Storage;
using SecureFolderFS.Storage.Extensions;

namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract
{
public static partial class AbstractPathHelpers
{
private const int SHORTENING_THRESHOLD = 220;
private const int MAX_SIDECAR_BYTES = 4096; // No legitimate ciphertext name approaches this upper bound

/// <summary>
/// Returns whether <paramref name="name"/> is a name-shortening sidecar file (<see cref="Constants.Names.SIDECAR_FILE_EXTENSION"/>).
/// Sidecar files are an internal implementation detail and should be excluded from vault enumeration.
/// </summary>
/// <returns>True, if the <paramref name="name"/> is a sidecar file; otherwise, false</returns>
public static bool IsSidecarName(string name)
{
return name.EndsWith(Constants.Names.SIDECAR_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Removes the shortened file extension from the specified filename if present.
/// </summary>
/// <returns>A <see cref="ReadOnlySpan{T}"/> or <see cref="char"/> without <see cref="Constants.Names.SHORTENED_FILE_EXTENSION"/>; otherwise, <paramref name="shortenedName"/>.</returns>
/// <remarks>
/// The returned <paramref name="shortenedName"/> may contain an extension other than <see cref="Constants.Names.SHORTENED_FILE_EXTENSION"/>.
/// </remarks>
public static ReadOnlySpan<char> RemoveShortenedExtension(string shortenedName)
{
return shortenedName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.Ordinal)
? shortenedName.AsSpan(0, shortenedName.Length - Constants.Names.SHORTENED_FILE_EXTENSION.Length)
: shortenedName.AsSpan();
}

/// <summary>
/// Computes a deterministic, filesystem-safe name base (no extension) for a full ciphertext name.
/// The result is a URL-safe Base64 encoding of the first 20 bytes of SHA-256(UTF-8(<paramref name="ciphertextName"/>)),
/// yielding a fixed 27-character string.
/// </summary>
[SkipLocalsInit]
private static string ComputeShortenedNameBase(string ciphertextName)
{
var nameBytes = Encoding.UTF8.GetBytes(ciphertextName);
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(nameBytes, hash);

return Base64Url.EncodeToString(hash.Slice(0, 20));
}

/// <summary>
/// Writes a sidecar file containing the full ciphertext name for a shortened file.
/// The sidecar is named <paramref name="shortenedBase"/> + <see cref="Constants.Names.SIDECAR_FILE_EXTENSION"/>.
/// Must be written before the shortened file/directory is created.
/// </summary>
private static async Task WriteSidecarAsync(
IFolder parentFolder,
string shortenedBase,
string fullCiphertextName,
CancellationToken cancellationToken)
{
if (parentFolder is not IModifiableFolder modifiableFolder)
throw new InvalidOperationException("Cannot write name-shortening sidecar: parent folder does not support modification.");

var sidecarName = shortenedBase + Constants.Names.SIDECAR_FILE_EXTENSION;
var sidecarFile = await modifiableFolder.CreateFileAsync(sidecarName, overwrite: true, cancellationToken);
await using var stream = await sidecarFile.OpenStreamAsync(FileAccess.Write, cancellationToken);
await stream.WriteAsync(Encoding.UTF8.GetBytes(fullCiphertextName), cancellationToken);
}

/// <summary>
/// Reads the full ciphertext name from a sidecar file.
/// Returns <see langword="null"/> if the sidecar does not exist or cannot be read.
/// </summary>
private static async Task<string?> ReadSidecarAsync(
IFolder parentFolder,
string shortenedBase,
CancellationToken cancellationToken)
{
try
{
var sidecarName = shortenedBase + Constants.Names.SIDECAR_FILE_EXTENSION;
var sidecarFile = await parentFolder.TryGetFileByNameAsync(sidecarName, cancellationToken);
if (sidecarFile is null)
return null;

await using var stream = await sidecarFile.OpenStreamAsync(FileAccess.Read, cancellationToken);
var buffer = new byte[MAX_SIDECAR_BYTES + 1];
var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (bytesRead > MAX_SIDECAR_BYTES)
return null; // Reject malformed/malicious sidecar

return Encoding.UTF8.GetString(buffer.AsSpan(0, bytesRead));
}
catch (Exception)
{
return null;
}
}
}
}
Loading