Skip to content

Commit 9c791a5

Browse files
committed
Begin work on shortening
1 parent ef029bb commit 9c791a5

3 files changed

Lines changed: 202 additions & 0 deletions

File tree

src/Core/SecureFolderFS.Core.FileSystem/Constants.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ public static class FileSystem
1818
public static class Names
1919
{
2020
public const string ENCRYPTED_FILE_EXTENSION = ".sffs";
21+
public const string SHORTENED_FILE_EXTENSION = ".sffsn";
22+
public const string SIDECAR_FILE_EXTENSION = ".sffsi";
2123
public const string DIRECTORY_ID_FILENAME = "dirid.iv";
2224
public const string RECYCLE_BIN_NAME = "recycle_bin";
2325
public const string RECYCLE_BIN_CONFIGURATION_FILENAME = "recycle_bin.cfg";

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract
99
{
1010
public static partial class AbstractPathHelpers
1111
{
12+
#region Encrypt Name Non-Materialized
13+
1214
/// <inheritdoc cref="EncryptNameAsync(string,OwlCore.Storage.IFolder,SecureFolderFS.Core.FileSystem.FileSystemSpecifics,System.Byte[],System.Threading.CancellationToken)"/>
1315
public static async Task<string> EncryptNameAsync(string plaintextName, IFolder ciphertextParentFolder,
1416
FileSystemSpecifics specifics, CancellationToken cancellationToken = default)
@@ -62,6 +64,81 @@ public static async Task<string> EncryptNameAsync(string plaintextName, IFolder
6264
return security.NameCrypt.EncryptName(plaintextName, result ? directoryId : ReadOnlySpan<byte>.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
6365
}
6466

67+
#endregion
68+
69+
#region Encrypt Name Materialized
70+
71+
/// <inheritdoc cref="EncryptNameForUseAsync(string,OwlCore.Storage.IFolder,SecureFolderFS.Core.FileSystem.FileSystemSpecifics,System.Byte[],System.Threading.CancellationToken)"/>
72+
public static async Task<string> EncryptNameForUseAsync(string plaintextName, IFolder ciphertextParentFolder,
73+
FileSystemSpecifics specifics, CancellationToken cancellationToken = default)
74+
{
75+
if (specifics.Security.NameCrypt is null)
76+
return plaintextName;
77+
78+
var directoryId = AllocateDirectoryId(specifics.Security, plaintextName);
79+
return await EncryptNameForUseAsync(plaintextName, ciphertextParentFolder, specifics, directoryId, cancellationToken);
80+
}
81+
82+
/// <summary>
83+
/// Encrypts the provided <paramref name="plaintextName"/> and materializes it.
84+
/// </summary>
85+
/// <param name="plaintextName">The name to encrypt.</param>
86+
/// <param name="ciphertextParentFolder">The ciphertext parent folder.</param>
87+
/// <param name="specifics">The <see cref="FileSystemSpecifics"/> instance associated with the item.</param>
88+
/// <param name="expendableDirectoryId">A buffer of size <see cref="Constants.DIRECTORY_ID_SIZE"/> which will be used to hold the Directory ID data.</param>
89+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that cancels this action.</param>
90+
/// <returns>A <see cref="Task"/> that represents the asynchronous operation. Value is an encrypted name with the appropriate file extension appended.</returns>
91+
public static async Task<string> EncryptNameForUseAsync(string plaintextName, IFolder ciphertextParentFolder,
92+
FileSystemSpecifics specifics, byte[]? expendableDirectoryId = null, CancellationToken cancellationToken = default)
93+
{
94+
if (specifics.Security.NameCrypt is null)
95+
return plaintextName;
96+
97+
expendableDirectoryId ??= AllocateDirectoryId(specifics.Security, plaintextName);
98+
var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, expendableDirectoryId, cancellationToken);
99+
100+
var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan<byte>.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
101+
if (SHORTENING_THRESHOLD > 0 && encryptedName.Length >= SHORTENING_THRESHOLD)
102+
{
103+
var shortenedBase = ComputeShortenedNameBase(encryptedName);
104+
await WriteSidecarAsync(ciphertextParentFolder, shortenedBase, encryptedName, cancellationToken);
105+
return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION;
106+
}
107+
108+
return encryptedName;
109+
}
110+
111+
/// <summary>
112+
/// Encrypts the provided <paramref name="plaintextName"/> and materializes it.
113+
/// </summary>
114+
/// <param name="plaintextName">The name to encrypt.</param>
115+
/// <param name="ciphertextParentFolder">The ciphertext parent folder.</param>
116+
/// <param name="contentFolder">The content folder.</param>
117+
/// <param name="security">The <see cref="Security"/> instance associated with the item.</param>
118+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that cancels this action.</param>
119+
/// <returns>A <see cref="Task"/> that represents the asynchronous operation. Value is an encrypted name with the appropriate file extension appended.</returns>
120+
public static async Task<string> EncryptNameForUseAsync(string plaintextName, IFolder ciphertextParentFolder, IFolder contentFolder,
121+
Security security, CancellationToken cancellationToken = default)
122+
{
123+
if (security.NameCrypt is null)
124+
return plaintextName;
125+
126+
var directoryId = AllocateDirectoryId(security, plaintextName);
127+
var result = await GetDirectoryIdAsync(ciphertextParentFolder, contentFolder, directoryId, cancellationToken);
128+
129+
var encryptedName = security.NameCrypt.EncryptName(plaintextName, result ? directoryId : ReadOnlySpan<byte>.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
130+
if (SHORTENING_THRESHOLD > 0 && encryptedName.Length >= SHORTENING_THRESHOLD)
131+
{
132+
var shortenedBase = ComputeShortenedNameBase(encryptedName);
133+
await WriteSidecarAsync(ciphertextParentFolder, shortenedBase, encryptedName, cancellationToken);
134+
return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION;
135+
}
136+
137+
return encryptedName;
138+
}
139+
140+
#endregion
141+
65142
/// <summary>
66143
/// Encrypts a plaintext name using the specified Directory ID and security parameters.
67144
/// </summary>
@@ -103,8 +180,23 @@ public static string EncryptNewName(string plaintextName, byte[] newDirectoryId,
103180
if (specifics.Security.NameCrypt is null)
104181
return ciphertextName;
105182

183+
// Sidecar files are internal bookkeeping - they have no plaintext name
184+
if (IsSidecarName(ciphertextName))
185+
return null;
186+
106187
try
107188
{
189+
// Resolve shortened names to their full ciphertext name via the paired sidecar
190+
if (ciphertextName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
191+
{
192+
var shortenedBase = RemoveShortenedExtension(ciphertextName).ToString();
193+
var resolvedName = await ReadSidecarAsync(ciphertextParentFolder, shortenedBase, cancellationToken);
194+
if (resolvedName is null)
195+
return null;
196+
197+
ciphertextName = resolvedName;
198+
}
199+
108200
expendableDirectoryId ??= AllocateDirectoryId(specifics.Security, ciphertextName);
109201
var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, expendableDirectoryId, cancellationToken);
110202

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using System;
2+
using System.Buffers.Text;
3+
using System.IO;
4+
using System.Runtime.CompilerServices;
5+
using System.Security.Cryptography;
6+
using System.Text;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using OwlCore.Storage;
10+
using SecureFolderFS.Storage.Extensions;
11+
12+
namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract
13+
{
14+
public static partial class AbstractPathHelpers
15+
{
16+
private const int SHORTENING_THRESHOLD = 220;
17+
private const int MAX_SIDECAR_BYTES = 4096; // No legitimate ciphertext name approaches this upper bound
18+
19+
/// <summary>
20+
/// Returns whether <paramref name="name"/> is a name-shortening sidecar file (<see cref="Constants.Names.SIDECAR_FILE_EXTENSION"/>).
21+
/// Sidecar files are an internal implementation detail and should be excluded from vault enumeration.
22+
/// </summary>
23+
/// <returns>True, if the <paramref name="name"/> is a sidecar file; otherwise, false</returns>
24+
public static bool IsSidecarName(string name)
25+
{
26+
return name.EndsWith(Constants.Names.SIDECAR_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase);
27+
}
28+
29+
/// <summary>
30+
/// Removes the shortened file extension from the specified filename if present.
31+
/// </summary>
32+
/// <returns>A <see cref="ReadOnlySpan{T}"/> or <see cref="char"/> without <see cref="Constants.Names.SHORTENED_FILE_EXTENSION"/>; otherwise, <paramref name="shortenedName"/>.</returns>
33+
/// <remarks>
34+
/// The returned <paramref name="shortenedName"/> may contain an extension other than <see cref="Constants.Names.SHORTENED_FILE_EXTENSION"/>.
35+
/// </remarks>
36+
public static ReadOnlySpan<char> RemoveShortenedExtension(string shortenedName)
37+
{
38+
return shortenedName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.Ordinal)
39+
? shortenedName.AsSpan(0, shortenedName.Length - Constants.Names.SHORTENED_FILE_EXTENSION.Length)
40+
: shortenedName.AsSpan();
41+
}
42+
43+
/// <summary>
44+
/// Computes a deterministic, filesystem-safe name base (no extension) for a full ciphertext name.
45+
/// The result is a URL-safe Base64 encoding of the first 20 bytes of SHA-256(UTF-8(<paramref name="ciphertextName"/>)),
46+
/// yielding a fixed 27-character string.
47+
/// </summary>
48+
[SkipLocalsInit]
49+
private static string ComputeShortenedNameBase(string ciphertextName)
50+
{
51+
var nameBytes = Encoding.UTF8.GetBytes(ciphertextName);
52+
Span<byte> hash = stackalloc byte[32];
53+
SHA256.HashData(nameBytes, hash);
54+
55+
return Base64Url.EncodeToString(hash.Slice(0, 20));
56+
}
57+
58+
/// <summary>
59+
/// Writes a sidecar file containing the full ciphertext name for a shortened file.
60+
/// The sidecar is named <paramref name="shortenedBase"/> + <see cref="Constants.Names.SIDECAR_FILE_EXTENSION"/>.
61+
/// Must be written before the shortened file/directory is created.
62+
/// </summary>
63+
private static async Task WriteSidecarAsync(
64+
IFolder parentFolder,
65+
string shortenedBase,
66+
string fullCiphertextName,
67+
CancellationToken cancellationToken)
68+
{
69+
if (parentFolder is not IModifiableFolder modifiableFolder)
70+
throw new InvalidOperationException("Cannot write name-shortening sidecar: parent folder does not support modification.");
71+
72+
var sidecarName = shortenedBase + Constants.Names.SIDECAR_FILE_EXTENSION;
73+
var sidecarFile = await modifiableFolder.CreateFileAsync(sidecarName, overwrite: true, cancellationToken);
74+
await using var stream = await sidecarFile.OpenStreamAsync(FileAccess.Write, cancellationToken);
75+
await stream.WriteAsync(Encoding.UTF8.GetBytes(fullCiphertextName), cancellationToken);
76+
}
77+
78+
/// <summary>
79+
/// Reads the full ciphertext name from a sidecar file.
80+
/// Returns <see langword="null"/> if the sidecar does not exist or cannot be read.
81+
/// </summary>
82+
private static async Task<string?> ReadSidecarAsync(
83+
IFolder parentFolder,
84+
string shortenedBase,
85+
CancellationToken cancellationToken)
86+
{
87+
try
88+
{
89+
var sidecarName = shortenedBase + Constants.Names.SIDECAR_FILE_EXTENSION;
90+
var sidecarFile = await parentFolder.TryGetFileByNameAsync(sidecarName, cancellationToken);
91+
if (sidecarFile is null)
92+
return null;
93+
94+
await using var stream = await sidecarFile.OpenStreamAsync(FileAccess.Read, cancellationToken);
95+
var buffer = new byte[MAX_SIDECAR_BYTES + 1];
96+
var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
97+
if (bytesRead > MAX_SIDECAR_BYTES)
98+
return null; // Reject malformed/malicious sidecar
99+
100+
return Encoding.UTF8.GetString(buffer.AsSpan(0, bytesRead));
101+
}
102+
catch (Exception)
103+
{
104+
return null;
105+
}
106+
}
107+
}
108+
}

0 commit comments

Comments
 (0)