Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesCtr256.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public static class AesCtr256

public static void Encrypt(ReadOnlySpan<byte> bytes, ReadOnlySpan<byte> key, ReadOnlySpan<byte> iv, Span<byte> 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);
Expand All @@ -25,7 +25,7 @@ public static bool Decrypt(ReadOnlySpan<byte> bytes, ReadOnlySpan<byte> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@
/// <inheritdoc/>
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);
}

Expand Down Expand Up @@ -358,7 +357,6 @@
/// <inheritdoc/>
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)
Expand Down Expand Up @@ -423,7 +421,7 @@
if (!FileSystem.Constants.OPT_IN_FOR_OPTIONAL_DEBUG_TRACING)
return result;

if (DisallowedTraceMethods.Contains(methodName))

Check warning on line 424 in src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs

View workflow job for this annotation

GitHub Actions / core (Debug, Dokany, net10.0)

Unreachable code detected
return result;

var message = FormatProviders.DokanFormat($"{methodName}('{fileName}', {info}, [{access}], [{share}], [{mode}], [{options}], [{attributes}]) -> {result}");
Expand All @@ -439,10 +437,10 @@
return result;
#endif

if (!FileSystem.Constants.OPT_IN_FOR_OPTIONAL_DEBUG_TRACING)

Check warning on line 440 in src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs

View workflow job for this annotation

GitHub Actions / core (Release, Dokany, net10.0)

Unreachable code detected
return result;

if (!Debugger.IsAttached)

Check warning on line 443 in src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs

View workflow job for this annotation

GitHub Actions / core (Debug, Dokany, net10.0)

Unreachable code detected
return result;

if (DisallowedTraceMethods.Contains(methodName))
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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
/// <summary>
/// Gets the HMAC-SHA256 tag binding this model's fields to the payload it describes.
/// </summary>
[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;
}

/// <summary>
/// Returns a copy of this model carrying a <see cref="PayloadMac"/> over its fields and <paramref name="itemName"/>.
/// </summary>
/// <param name="itemName">The name of the payload in the recycle bin that this model describes.</param>
/// <param name="security">The <see cref="Security"/> instance holding the vault's MAC key.</param>
public RecycleBinItemDataModel WithMac(string itemName, Security security)
{
return this with { PayloadMac = ComputeMac(this, itemName, security) };
}

/// <summary>
/// Determines whether <see cref="PayloadMac"/> authenticates this model against <paramref name="itemName"/>.
/// </summary>
/// <param name="itemName">The name of the payload in the recycle bin that this model describes.</param>
/// <param name="security">The <see cref="Security"/> instance holding the vault's MAC key.</param>
/// <remarks>
/// 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.
/// </remarks>
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<byte> 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<byte> 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<byte> value, bool isPresent)
{
// The presence flag keeps an absent field distinct from a present-but-empty one
Span<byte> 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);
}
}

/// <summary>
/// Determines whether <paramref name="name"/> is a single path component that cannot escape its parent.
/// </summary>
/// <param name="name">The name to check.</param>
/// <remarks>
/// Both separators are checked regardless of the running platform.
/// </remarks>
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.");

Expand Down Expand Up @@ -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.");

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -287,7 +287,7 @@ internal static async Task<long> 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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -182,22 +183,25 @@ private static async Task<long> GetFolderPlaintextSizeAsync(IFolder ciphertextFo
/// <summary>
/// Serializes <paramref name="dataModel"/> into <paramref name="configurationFile"/>, truncating any previous content.
/// </summary>
internal static Task WriteItemDataModelAsync(IFile configurationFile, RecycleBinItemDataModel dataModel, IAsyncSerializer<Stream> streamSerializer, CancellationToken cancellationToken)
internal static Task WriteItemDataModelAsync(IFile configurationFile, RecycleBinItemDataModel dataModel, Security security, IAsyncSerializer<Stream> 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<object?>(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<RecycleBinItemDataModel> GetItemDataModelAsync(IStorableChild item, IFolder recycleBin, IAsyncSerializer<Stream> streamSerializer, CancellationToken cancellationToken = default)
public static async Task<RecycleBinItemDataModel> GetItemDataModelAsync(IStorableChild item, IFolder recycleBin, Security security, IAsyncSerializer<Stream> streamSerializer, CancellationToken cancellationToken = default)
{
// Get the configuration file
var configurationFile = !item.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
Expand All @@ -216,6 +220,11 @@ public static async Task<RecycleBinItemDataModel> 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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -212,10 +213,13 @@ static bool IsRecentlyCreated(string path)
/// <summary>
/// Serializes <paramref name="dataModel"/> into the file at <paramref name="configurationPath"/>, truncating any previous content.
/// </summary>
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();
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading
Loading