diff --git a/SecureFolderFS.Public.slnx b/SecureFolderFS.Public.slnx index 2d771d68c..84f2aaa1d 100644 --- a/SecureFolderFS.Public.slnx +++ b/SecureFolderFS.Public.slnx @@ -36,6 +36,7 @@ + diff --git a/SecureFolderFS.slnx b/SecureFolderFS.slnx index ddfb900cc..b73467008 100644 --- a/SecureFolderFS.slnx +++ b/SecureFolderFS.slnx @@ -93,6 +93,8 @@ + + diff --git a/global.json b/global.json index bd19c39ea..0bc2e02cd 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "msbuild-sdks": { - "Uno.Sdk": "6.6.29" + "Uno.Sdk": "6.6.33" } } \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesGcm256.cs b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesGcm256.cs index b570f207b..08366e397 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesGcm256.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesGcm256.cs @@ -1,19 +1,37 @@ using System; using System.Security.Cryptography; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Modes; +using Org.BouncyCastle.Crypto.Parameters; namespace SecureFolderFS.Core.Cryptography.Cipher { public static class AesGcm256 { + private const int TAG_SIZE = 16; + public static void Encrypt(ReadOnlySpan bytes, ReadOnlySpan key, ReadOnlySpan nonce, Span tag, Span result, ReadOnlySpan associatedData) { - using var aesGcm = new AesGcm(key, Constants.Crypto.Chunks.AesGcm.CHUNK_TAG_SIZE); + if (Constants.PreferBouncyCastle) + { + BcEncrypt(bytes, key, nonce, tag, result, associatedData); + return; + } + + using var aesGcm = new AesGcm(key, TAG_SIZE); aesGcm.Encrypt(nonce, bytes, result, tag, associatedData); } public static void Decrypt(ReadOnlySpan bytes, ReadOnlySpan key, ReadOnlySpan nonce, ReadOnlySpan tag, Span result, ReadOnlySpan associatedData) { - using var aesGcm = new AesGcm(key, Constants.Crypto.Chunks.AesGcm.CHUNK_TAG_SIZE); + if (Constants.PreferBouncyCastle) + { + BcDecrypt(bytes, key, nonce, tag, result, associatedData); + return; + } + + using var aesGcm = new AesGcm(key, TAG_SIZE); aesGcm.Decrypt(nonce, bytes, tag, result, associatedData); } @@ -29,5 +47,44 @@ public static bool TryDecrypt(ReadOnlySpan bytes, ReadOnlySpan key, return false; } } + + private static void BcEncrypt(ReadOnlySpan bytes, ReadOnlySpan key, ReadOnlySpan nonce, Span tag, Span result, ReadOnlySpan associatedData) + { + var gcm = new GcmBlockCipher(new AesEngine()); + gcm.Init(true, new AeadParameters(new KeyParameter(key.ToArray()), TAG_SIZE * 8, nonce.ToArray(), associatedData.ToArray())); + + // BC concatenates ciphertext || tag into a single output buffer. + var output = new byte[gcm.GetOutputSize(bytes.Length)]; + var written = gcm.ProcessBytes(bytes.ToArray(), 0, bytes.Length, output, 0); + gcm.DoFinal(output, written); + + output.AsSpan(0, bytes.Length).CopyTo(result); + output.AsSpan(bytes.Length, TAG_SIZE).CopyTo(tag); + } + + private static void BcDecrypt(ReadOnlySpan bytes, ReadOnlySpan key, ReadOnlySpan nonce, ReadOnlySpan tag, Span result, ReadOnlySpan associatedData) + { + var gcm = new GcmBlockCipher(new AesEngine()); + gcm.Init(false, new AeadParameters(new KeyParameter(key.ToArray()), TAG_SIZE * 8, nonce.ToArray(), associatedData.ToArray())); + + // BC expects ciphertext || tag as one input buffer. + var input = new byte[bytes.Length + tag.Length]; + bytes.CopyTo(input); + tag.CopyTo(input.AsSpan(bytes.Length)); + + var output = new byte[gcm.GetOutputSize(input.Length)]; + try + { + var written = gcm.ProcessBytes(input, 0, input.Length, output, 0); + gcm.DoFinal(output, written); + } + catch (InvalidCipherTextException ex) + { + // Match the native AesGcm contract so TryDecrypt and callers behave identically. + throw new CryptographicException("The authentication tag did not match.", ex); + } + + output.AsSpan(0, result.Length).CopyTo(result); + } } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv256.cs b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv256.cs index 74c3b22bf..82c1e28ea 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv256.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/AesSiv256.cs @@ -1,42 +1,73 @@ -using System; +using System; using System.Runtime.CompilerServices; +using System.Security.Cryptography; using Miscreant; namespace SecureFolderFS.Core.Cryptography.Cipher { public sealed class AesSiv256 : IDisposable { - private readonly Aead _aesCmacSiv; + private readonly Aead? _aesCmacSiv; + private readonly bool _preferBouncyCastle; - private AesSiv256(Aead aesCmacSiv) + /// + /// Holds the concatenated DEK and MAC key. + /// + /// + /// Allocated pinned so the garbage collector cannot relocate it and leave copies of the + /// master keys scattered across the heap, and zeroed in so it does + /// not survive in a memory image after the vault is locked. + /// + private readonly byte[] _longKey; + + private AesSiv256(Aead? aesCmacSiv, byte[] longKey, bool preferBouncyCastle) { _aesCmacSiv = aesCmacSiv; + _longKey = longKey; + _preferBouncyCastle = preferBouncyCastle; } public static AesSiv256 CreateInstance(ReadOnlySpan dekKey, ReadOnlySpan macKey) { // The longKey will be split into two keys - one for S2V and the other one for CTR - var longKey = new byte[dekKey.Length + macKey.Length]; - var longKeySpan = longKey.AsSpan(); + var longKey = GC.AllocateArray(dekKey.Length + macKey.Length, pinned: true); + try + { + var longKeySpan = longKey.AsSpan(); - // Copy keys - dekKey.CopyTo(longKeySpan); - macKey.CopyTo(longKeySpan.Slice(dekKey.Length)); + // Copy keys + dekKey.CopyTo(longKeySpan); + macKey.CopyTo(longKeySpan.Slice(dekKey.Length)); - var aesCmacSiv = Aead.CreateAesCmacSiv(longKey); - return new AesSiv256(aesCmacSiv); + if (Constants.PreferBouncyCastle) + return new AesSiv256(null, longKey, true); + + var aesCmacSiv = Aead.CreateAesCmacSiv(longKey); + return new AesSiv256(aesCmacSiv, longKey, false); + } + catch (Exception) + { + CryptographicOperations.ZeroMemory(longKey); + throw; + } } [MethodImpl(MethodImplOptions.Synchronized)] public byte[] Encrypt(ReadOnlySpan bytes, ReadOnlySpan associatedData) { - return _aesCmacSiv.Seal(bytes.ToArray(), data: associatedData.ToArray()); + if (_preferBouncyCastle) + return BouncyCastleAesSiv.Seal(_longKey, associatedData, bytes); + + return _aesCmacSiv!.Seal(bytes.ToArray(), data: associatedData.ToArray()); } [MethodImpl(MethodImplOptions.Synchronized)] public byte[] Decrypt(ReadOnlySpan bytes, ReadOnlySpan associatedData) { - return _aesCmacSiv.Open(bytes.ToArray(), data: associatedData.ToArray()); + if (_preferBouncyCastle) + return BouncyCastleAesSiv.Open(_longKey, associatedData, bytes); + + return _aesCmacSiv!.Open(bytes.ToArray(), data: associatedData.ToArray()); } /// @@ -44,13 +75,18 @@ public void Dispose() { try { - _aesCmacSiv.Dispose(); + _aesCmacSiv?.Dispose(); } catch (Exception ex) { // TODO: Investigate. Sometimes an exception is thrown when disposing the Aead instance _ = ex; } + finally + { + // Zero the master key material last, so the AEAD is torn down before its key disappears + CryptographicOperations.ZeroMemory(_longKey); + } } } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/Argon2id.cs b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/Argon2id.cs index c729bac73..87c8fdd2e 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/Argon2id.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/Argon2id.cs @@ -1,29 +1,95 @@ using System; +using System.Security.Cryptography; +using System.Threading.Tasks; namespace SecureFolderFS.Core.Cryptography.Cipher { public static class Argon2id { - public static void V2_DeriveKey(ReadOnlySpan password, ReadOnlySpan salt, Span result) + /// + /// Derives a KEK without blocking the calling thread. + /// + /// The password. + /// The salt. + /// The result. + /// + /// Parallelism lanes: defined by .
+ /// Iterations: defined by .
+ /// Memory: defined by .
+ ///
+ public static async Task DeriveKeyAsync(byte[] password, byte[] salt, byte[] result) { - using var argon2id = new Konscious.Security.Cryptography.Argon2id(password.ToArray()); - argon2id.Salt = salt.ToArray(); - argon2id.DegreeOfParallelism = 8; - argon2id.Iterations = 8; - argon2id.MemorySize = 102400; + using var argon2id = new Konscious.Security.Cryptography.Argon2id(password); + argon2id.Salt = salt; + argon2id.DegreeOfParallelism = Constants.Crypto.Argon2.DEGREE_OF_PARALLELISM; + argon2id.Iterations = Constants.Crypto.Argon2.ITERATIONS; + argon2id.MemorySize = Constants.Crypto.Argon2.MEMORY_SIZE_KIBIBYTES; - argon2id.GetBytes(Constants.KeyTraits.ARGON2_KEK_LENGTH).CopyTo(result); + var kek = await argon2id.GetBytesAsync(Constants.KeyTraits.ARGON2_KEK_LENGTH).ConfigureAwait(false); + kek.CopyTo(result, 0); + CryptographicOperations.ZeroMemory(kek); } + /// + /// Derives a KEK synchronously. + /// + /// The password. + /// The salt. + /// The result. + /// + /// Parallelism lanes: defined by .
+ /// Iterations: defined by .
+ /// Memory: defined by .
+ ///
public static void DeriveKey(ReadOnlySpan password, ReadOnlySpan salt, Span result) { - using var argon2id = new Konscious.Security.Cryptography.Argon2id(password.ToArray()); - argon2id.Salt = salt.ToArray(); - argon2id.DegreeOfParallelism = Constants.Crypto.Argon2.DEGREE_OF_PARALLELISM; - argon2id.Iterations = Constants.Crypto.Argon2.ITERATIONS; - argon2id.MemorySize = Constants.Crypto.Argon2.MEMORY_SIZE_KIBIBYTES; + DeriveKeyCore( + password, + salt, + result, + Constants.Crypto.Argon2.DEGREE_OF_PARALLELISM, + Constants.Crypto.Argon2.ITERATIONS, + Constants.Crypto.Argon2.MEMORY_SIZE_KIBIBYTES); + } + + public static void V2_DeriveKey(ReadOnlySpan password, ReadOnlySpan salt, Span result) + { + DeriveKeyCore( + password, + salt, + result, + degreeOfParallelism: 8, + iterations: 8, + memorySize: 102400); + } + + private static void DeriveKeyCore( + ReadOnlySpan password, + ReadOnlySpan salt, + Span result, + int degreeOfParallelism, + int iterations, + int memorySize) + { + var passwordCopy = password.ToArray(); + byte[]? kek = null; + try + { + using var argon2id = new Konscious.Security.Cryptography.Argon2id(passwordCopy); + argon2id.Salt = salt.ToArray(); + argon2id.DegreeOfParallelism = degreeOfParallelism; + argon2id.Iterations = iterations; + argon2id.MemorySize = memorySize; - argon2id.GetBytes(Constants.KeyTraits.ARGON2_KEK_LENGTH).CopyTo(result); + kek = argon2id.GetBytes(Constants.KeyTraits.ARGON2_KEK_LENGTH); + kek.CopyTo(result); + } + finally + { + CryptographicOperations.ZeroMemory(passwordCopy); + if (kek is not null) + CryptographicOperations.ZeroMemory(kek); + } } } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/BouncyCastleAesSiv.cs b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/BouncyCastleAesSiv.cs new file mode 100644 index 000000000..edbe3c5f0 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/BouncyCastleAesSiv.cs @@ -0,0 +1,155 @@ +using System; +using System.Security.Cryptography; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Macs; +using Org.BouncyCastle.Crypto.Parameters; + +namespace SecureFolderFS.Core.Cryptography.Cipher +{ + /// + /// A pure-managed AES-CMAC-SIV (RFC 5297) implementation built on BouncyCastle. + /// + internal static class BouncyCastleAesSiv + { + private const int BlockSize = 16; + + /// + /// Seals with a single associated-data item, returning + /// SIV(16) || ciphertext. + /// + public static byte[] Seal(ReadOnlySpan key, ReadOnlySpan associatedData, ReadOnlySpan plaintext) + { + SplitKey(key, out var macKey, out var ctrKey); + + var message = plaintext.ToArray(); + var v = S2V(macKey, associatedData, message); + + var output = new byte[BlockSize + message.Length]; + v.CopyTo(output.AsSpan(0, BlockSize)); + Ctr(ctrKey, v, message, 0, message.Length, output, BlockSize); + return output; + } + + /// + /// Opens SIV(16) || ciphertext, returning the plaintext or throwing on an integrity failure. + /// + public static byte[] Open(ReadOnlySpan key, ReadOnlySpan associatedData, ReadOnlySpan input) + { + if (input.Length < BlockSize) + throw new CryptographicException("Malformed or corrupt ciphertext."); + + SplitKey(key, out var macKey, out var ctrKey); + + var v = input.Slice(0, BlockSize).ToArray(); + var ciphertext = input.Slice(BlockSize); + + var plaintext = new byte[ciphertext.Length]; + Ctr(ctrKey, v, ciphertext.ToArray(), 0, ciphertext.Length, plaintext, 0); + + var expected = S2V(macKey, associatedData, plaintext); + if (!CryptographicOperations.FixedTimeEquals(expected, v)) + throw new CryptographicException("Malformed or corrupt ciphertext."); + + return plaintext; + } + + private static void SplitKey(ReadOnlySpan key, out byte[] macKey, out byte[] ctrKey) + { + if (key.Length != 32 && key.Length != 64) + throw new CryptographicException("Specified key is not a valid size for this algorithm."); + + var half = key.Length / 2; + macKey = key.Slice(0, half).ToArray(); + ctrKey = key.Slice(half).ToArray(); + } + + /// RFC 5297 S2V over a single header string and the message. + private static byte[] S2V(byte[] macKey, ReadOnlySpan header, byte[] message) + { + var d = Cmac(macKey, new byte[BlockSize]); + + // Single associated-data item + Dbl(d); + Xor(d, Cmac(macKey, header.ToArray()), BlockSize); + + if (message.Length >= BlockSize) + { + // T = message with its last block XORed into D + var t = (byte[])message.Clone(); + var offset = t.Length - BlockSize; + for (var i = 0; i < BlockSize; i++) + t[offset + i] ^= d[i]; + + return Cmac(macKey, t); + } + + var padded = new byte[BlockSize]; + message.CopyTo(padded, 0); + padded[message.Length] = 0x80; // pad + + Dbl(d); + Xor(d, padded, BlockSize); + return Cmac(macKey, d); + } + + private static byte[] Cmac(byte[] key, byte[] data) + { + var mac = new CMac(new AesEngine()); + mac.Init(new KeyParameter(key)); + mac.BlockUpdate(data, 0, data.Length); + var result = new byte[mac.GetMacSize()]; + mac.DoFinal(result, 0); + return result; + } + + private static void Ctr(byte[] key, byte[] siv, byte[] input, int inputOffset, int length, byte[] output, int outputOffset) + { + // Zero out the two bits that RFC 5297 reserves so the counter never wraps into them. + var counter = (byte[])siv.Clone(); + counter[counter.Length - 8] &= 0x7F; + counter[counter.Length - 4] &= 0x7F; + + // Manual CTR mode in which the full 128-bit counter is incremented (big-endian) and its AES + // encryption is XORed into the data (the partial final block is handled by only consuming as many keystream bytes as remain). + var engine = new AesEngine(); + engine.Init(true, new KeyParameter(key)); + + var keystream = new byte[BlockSize]; + for (var position = 0; position < length; position += BlockSize) + { + engine.ProcessBlock(counter, 0, keystream, 0); + + var count = Math.Min(BlockSize, length - position); + for (var i = 0; i < count; i++) + output[outputOffset + position + i] = (byte)(input[inputOffset + position + i] ^ keystream[i]); + + IncrementBigEndian(counter); + } + } + + private static void IncrementBigEndian(byte[] counter) + { + for (var i = counter.Length - 1; i >= 0; i--) + { + if (++counter[i] != 0) + break; + } + } + + /// Doubles a 128-bit value in GF(2^128) (the "dbl" operation). + private static void Dbl(byte[] block) + { + var carry = block[0] >> 7; + for (var i = 0; i < BlockSize - 1; i++) + block[i] = (byte)((block[i] << 1) | (block[i + 1] >> 7)); + + block[BlockSize - 1] = (byte)((block[BlockSize - 1] << 1) ^ (carry == 1 ? 0x87 : 0x00)); + } + + private static void Xor(byte[] destination, byte[] source, int length) + { + for (var i = 0; i < length; i++) + destination[i] ^= source[i]; + } + } +} diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/Rfc3394KeyWrap.cs b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/Rfc3394KeyWrap.cs index adf013297..a8bd530f0 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Cipher/Rfc3394KeyWrap.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Cipher/Rfc3394KeyWrap.cs @@ -1,33 +1,61 @@ -using RFC3394; +using RFC3394; using System; +using System.Security.Cryptography; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Parameters; namespace SecureFolderFS.Core.Cryptography.Cipher { - // TODO: Needs docs public sealed class Rfc3394KeyWrap : IDisposable { - private readonly RFC3394Algorithm _rfc3394; + private readonly RFC3394Algorithm? _rfc3394; public Rfc3394KeyWrap() { - _rfc3394 = new(); + _rfc3394 = Constants.PreferBouncyCastle ? null : new(); } public byte[] WrapKey(ReadOnlySpan bytes, ReadOnlySpan kek) { - return _rfc3394.Wrap(kek: kek.ToArray(), plainKey: bytes.ToArray()); + if (_rfc3394 is not null) + return _rfc3394.Wrap(kek: kek.ToArray(), plainKey: bytes.ToArray()); + + var engine = new AesWrapEngine(); + engine.Init(true, new KeyParameter(kek.ToArray())); + var plain = bytes.ToArray(); + return engine.Wrap(plain, 0, plain.Length); } public void UnwrapKey(ReadOnlySpan bytes, ReadOnlySpan kek, Span result) { - var result2 = _rfc3394.Unwrap(kek: kek.ToArray(), wrappedKey: bytes.ToArray()); - result2.CopyTo(result); + if (_rfc3394 is not null) + { + var unwrapped = _rfc3394.Unwrap(kek: kek.ToArray(), wrappedKey: bytes.ToArray()); + unwrapped.CopyTo(result); + return; + } + + var engine = new AesWrapEngine(); + engine.Init(false, new KeyParameter(kek.ToArray())); + var wrapped = bytes.ToArray(); + try + { + var unwrapped = engine.Unwrap(wrapped, 0, wrapped.Length); + unwrapped.CopyTo(result); + } + catch (InvalidCipherTextException ex) + { + // The native RFC3394.net path throws CryptographicException on an integrity failure; + // surface the same type so unlock's wrong-credential handling is unchanged. + throw new CryptographicException("The wrapped key failed its integrity check.", ex); + } } /// public void Dispose() { - _rfc3394.Dispose(); + _rfc3394?.Dispose(); } } } diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Constants.cs b/src/Core/SecureFolderFS.Core.Cryptography/Constants.cs index ad1d14a1e..805feb3af 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/Constants.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/Constants.cs @@ -1,7 +1,11 @@ -namespace SecureFolderFS.Core.Cryptography +using System; + +namespace SecureFolderFS.Core.Cryptography { public static class Constants { + public static bool PreferBouncyCastle { get; set; } = OperatingSystem.IsBrowser(); + public static class KeyTraits { public const string KEY_TEXT_SEPARATOR = "@@@"; diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Jwe/AccountKeyHelper.cs b/src/Core/SecureFolderFS.Core.Cryptography/Jwe/AccountKeyHelper.cs new file mode 100644 index 000000000..493c6d4f5 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/Jwe/AccountKeyHelper.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Security.Cryptography; +using Jose; + +namespace SecureFolderFS.Core.Cryptography.Jwe +{ + /// + /// Provides PBES2-based JWE operations for Account Key (passphrase) wrapping of EC private keys. + /// Used to bootstrap new devices when no device-specific JWE exists yet. + /// + public static class AccountKeyHelper + { + /// + /// Iteration count used when wrapping. + /// + /// + /// Unwrap accepts [, + /// ] so this can be raised without stranding existing account + /// keys. Must stay in sync with crypto-interop.js (WASM) and JweFormatValidator (server). + /// + private const int ACCOUNT_KEY_PBES2_ITERATIONS = 600_000; + + /// + /// Lowest accepted iteration count (OWASP minimum for PBKDF2-HMAC-SHA512). + /// + private const int MIN_PBES2_ITERATIONS = 210_000; + + /// + /// Highest accepted iteration count; bounds p2c amplification DoS. + /// + private const int MAX_PBES2_ITERATIONS = 1_000_000; + + /// + /// Iteration count for the Account Key verifier derivation. Pinned independently of . + /// + /// + /// The verifier must derive to the same value for the + /// lifetime of a registration, so changing this invalidates every stored verifier hash (users + /// would have to re-register via setup or a passphrase change). Bump the context version string + /// together with this value if it ever changes. + /// + private const int ACCOUNT_VERIFIER_ITERATIONS = 600_000; + + /// + /// Domain-separation context for the verifier derivation. Distinct from the PBES2 salt input + /// ("PBES2-HS512+A256KW" || 0x00 || p2s) so the verifier is cryptographically independent of + /// the JWE key-encryption key and cannot be used to unwrap the Account Key JWE. + /// + private const string ACCOUNT_VERIFIER_CONTEXT = "SFFS-account-verifier-v1"; + + private const string ACCOUNT_KEY_ALG = "PBES2-HS512+A256KW"; + private const string ACCOUNT_KEY_ENC = "A256GCM"; + + /// + /// jose-jwt caps PBES2-HS512 p2c at 120,000 by default (its own amplification-DoS guard), + /// which rejects our 600k wrap count. Re-register the key management with our accepted range + /// so both bounds are enforced by the library on wrap and unwrap. + /// + private static readonly JwtSettings Pbes2Settings = new JwtSettings().RegisterJwa( + JweAlgorithm.PBES2_HS512_A256KW, + new Pbse2HmacShaKeyManagementWithAesKeyWrap( + 256, new AesKeyWrapManagement(256), + maxIterations: MAX_PBES2_ITERATIONS, + minIterations: MIN_PBES2_ITERATIONS)); + + /// + /// Wraps an EC private key (in DER format) under a user-provided passphrase using PBES2-HS512+A256KW / A256GCM. + /// Uses 256-bit AES key wrapping for post-quantum security margin. + /// + /// The EC private key bytes (DER-encoded) to wrap. + /// The user-provided Account Key passphrase. + /// A JWE compact serialization string containing the encrypted private key. + public static string Wrap(byte[] privateKeyBytes, string passphrase) + { + var headers = new Dictionary + { + ["p2c"] = ACCOUNT_KEY_PBES2_ITERATIONS + }; + + return JWT.EncodeBytes(privateKeyBytes, passphrase, JweAlgorithm.PBES2_HS512_A256KW, JweEncryption.A256GCM, extraHeaders: headers, settings: Pbes2Settings); + } + + /// + /// Unwraps an EC private key from a PBES2-protected JWE using the Account Key passphrase. + /// + /// The JWE compact serialization containing the wrapped private key. + /// The user-provided Account Key passphrase. + /// The EC private key bytes (DER-encoded). + public static byte[] Unwrap(string jweCompact, string passphrase) + { + ValidateAccountKeyHeader(jweCompact); + return JWT.DecodeBytes(jweCompact, passphrase, JweAlgorithm.PBES2_HS512_A256KW, JweEncryption.A256GCM, settings: Pbes2Settings); + } + + private static void ValidateAccountKeyHeader(string jweCompact) + { + IDictionary headers; + try + { + headers = JWT.Headers(jweCompact); + } + catch (Exception ex) when (ex is JoseException or ArgumentException or FormatException) + { + throw new CryptographicException("Invalid Account Key JWE header.", ex); + } + + if (!headers.TryGetValue("alg", out var alg) || + !string.Equals(Convert.ToString(alg, CultureInfo.InvariantCulture), ACCOUNT_KEY_ALG, StringComparison.Ordinal)) + { + throw new CryptographicException("Unsupported Account Key JWE algorithm."); + } + + if (!headers.TryGetValue("enc", out var enc) || + !string.Equals(Convert.ToString(enc, CultureInfo.InvariantCulture), ACCOUNT_KEY_ENC, StringComparison.Ordinal)) + { + throw new CryptographicException("Unsupported Account Key JWE content encryption."); + } + + if (!headers.TryGetValue("p2c", out var p2c) || + !TryConvertToInt64(p2c, out var iterations) || + iterations is < MIN_PBES2_ITERATIONS or > MAX_PBES2_ITERATIONS) + { + throw new CryptographicException("Unexpected Account Key PBES2 iteration count."); + } + } + + private static bool TryConvertToInt64(object value, out long result) + { + try + { + result = Convert.ToInt64(value, CultureInfo.InvariantCulture); + return true; + } + catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) + { + result = 0; + return false; + } + } + + /// + /// Derives the Account Key verifier that facilitates passphrase-derived + /// proof-of-possession token sent to the server in place of the raw passphrase. + /// + /// + /// The server stores only a hash of the token, so neither a + /// compromised server nor a leaked database learns the passphrase or a value capable of + /// unwrapping the Account Key JWE. Must produce byte-identical output to + /// deriveAccountVerifier on the WASM end. + /// + /// The user-provided Account Key passphrase. + /// The user's OIDC subject, used as a per-user salt component. + /// The verifier as a standard base64 string (32 bytes). + public static string DeriveVerifier(string passphrase, string userId) + { + var contextBytes = System.Text.Encoding.UTF8.GetBytes(ACCOUNT_VERIFIER_CONTEXT); + var userIdBytes = System.Text.Encoding.UTF8.GetBytes(userId); + var salt = new byte[contextBytes.Length + 1 + userIdBytes.Length]; + contextBytes.CopyTo(salt, 0); + salt[contextBytes.Length] = 0x00; + userIdBytes.CopyTo(salt, contextBytes.Length + 1); + + var verifier = Rfc2898DeriveBytes.Pbkdf2( + passphrase, salt, ACCOUNT_VERIFIER_ITERATIONS, HashAlgorithmName.SHA512, outputLength: 32); + try + { + return Convert.ToBase64String(verifier); + } + finally + { + CryptographicOperations.ZeroMemory(verifier); + } + } + + /// + /// Wraps a user's EC private key for Account Key bootstrap using PBES2-HS512+A256KW / A256GCM. + /// The private key is stored in JWK format inside the JWE for cross-platform compatibility. + /// + /// The user's EC private key to wrap. + /// The user-provided Account Key passphrase. + /// A JWE compact serialization containing the encrypted user private key (as JWK). + public static string WrapUserKey(ECDiffieHellman userPrivateKey, string passphrase) + { + var privateKeyJwk = EcKeyHelper.ExportPrivateKeyJwk(userPrivateKey); + var privateKeyBytes = System.Text.Encoding.UTF8.GetBytes(privateKeyJwk); + try + { + return Wrap(privateKeyBytes, passphrase); + } + finally + { + CryptographicOperations.ZeroMemory(privateKeyBytes); + } + } + + /// + /// Unwraps a user's EC private key from an Account Key-protected JWE. + /// Expects the JWE to contain the private key in JWK format. + /// + /// The JWE compact serialization containing the wrapped user private key. + /// The user-provided Account Key passphrase. + /// An instance with the decrypted user private key. + public static ECDiffieHellman UnwrapUserKey(string jweCompact, string passphrase) + { + var privateKeyBytes = Unwrap(jweCompact, passphrase); + try + { + var jwk = System.Text.Encoding.UTF8.GetString(privateKeyBytes); + return EcKeyHelper.ImportPrivateKeyJwk(jwk); + } + finally + { + CryptographicOperations.ZeroMemory(privateKeyBytes); + } + } + } +} diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Jwe/EcKeyHelper.cs b/src/Core/SecureFolderFS.Core.Cryptography/Jwe/EcKeyHelper.cs new file mode 100644 index 000000000..5a1501079 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/Jwe/EcKeyHelper.cs @@ -0,0 +1,201 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace SecureFolderFS.Core.Cryptography.Jwe +{ + /// + /// Provides EC P-256 key pair generation, JWK serialization, and import/export operations. + /// + public static class EcKeyHelper + { + /// + /// Generates a new EC P-256 key pair for ECDH key agreement. + /// + public static ECDiffieHellman GenerateKeyPair() + { + return ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + } + + /// + /// Exports the public key of an instance as a JWK JSON string. + /// + /// The key pair to export the public component from. + /// A JSON string in JWK format containing the public key. + public static string ExportPublicKeyJwk(ECDiffieHellman key) + { + var parameters = key.ExportParameters(includePrivateParameters: false); + return SerializeJwk(parameters, includePrivate: false); + } + + /// + /// Exports the full key pair (public + private) as a JWK JSON string. + /// + /// The key pair to export. + /// A JSON string in JWK format containing both public and private key components. + public static string ExportPrivateKeyJwk(ECDiffieHellman key) + { + var parameters = key.ExportParameters(includePrivateParameters: true); + return SerializeJwk(parameters, includePrivate: true); + } + + /// + /// Exports the private key as a DER-encoded byte array suitable for secure storage. + /// + /// The key pair to export the private key from. + /// A byte array containing the private key in SEC1/ECPrivateKey format. + public static byte[] ExportPrivateKeyBytes(ECDiffieHellman key) + { + return key.ExportECPrivateKey(); + } + + /// + /// Imports an EC P-256 public key from a JWK JSON string. + /// + /// The JWK JSON string containing the public key. + /// An instance with only the public key component. + public static ECDiffieHellman ImportPublicKeyJwk(string jwk) + { + var parameters = DeserializeJwk(jwk); + parameters.D = null; + var ecdh = ECDiffieHellman.Create(); + ecdh.ImportParameters(parameters); + return ecdh; + } + + /// + /// Imports an EC P-256 key pair from a JWK JSON string that includes the private key. + /// + /// The JWK JSON string containing both public and private key components. + /// An instance with both public and private key components. + public static ECDiffieHellman ImportPrivateKeyJwk(string jwk) + { + var parameters = DeserializeJwk(jwk); + var ecdh = ECDiffieHellman.Create(); + ecdh.ImportParameters(parameters); + return ecdh; + } + + /// + /// Imports a private key from a DER-encoded byte array (SEC1/ECPrivateKey format). + /// + /// The DER-encoded private key bytes. + /// An instance with the imported private key. + public static ECDiffieHellman ImportPrivateKeyBytes(byte[] privateKeyBytes) + { + var ecdh = ECDiffieHellman.Create(); + ecdh.ImportECPrivateKey(privateKeyBytes, out _); + return ecdh; + } + + /// + /// Compares the public EC coordinates in two P-256 JWKs. + /// + public static bool PublicJwksEqual(string leftJwk, string rightJwk) + { + var left = DeserializeJwk(leftJwk); + var right = DeserializeJwk(rightJwk); + + return left.Q.X is not null && + left.Q.Y is not null && + right.Q.X is not null && + right.Q.Y is not null && + CryptographicOperations.FixedTimeEquals(left.Q.X, right.Q.X) && + CryptographicOperations.FixedTimeEquals(left.Q.Y, right.Q.Y); + } + + /// + /// Computes the JWK Thumbprint (RFC 7638) for an EC P-256 public key JWK. + /// Uses SHA-256 over the lexicographically-sorted required members: crv, kty, x, y. + /// + /// The public key as a JWK JSON string. + /// A base64url-encoded SHA-256 thumbprint. + public static string ComputeJwkThumbprint(string publicKeyJwk) + { + using var doc = JsonDocument.Parse(publicKeyJwk); + var root = doc.RootElement; + + var crv = root.GetProperty("crv").GetString(); + var kty = root.GetProperty("kty").GetString(); + var x = root.GetProperty("x").GetString(); + var y = root.GetProperty("y").GetString(); + + // RFC 7638: canonical JSON with required members in lexicographic order + // For EC keys the required members are: crv, kty, x, y + var canonical = $"{{\"crv\":\"{crv}\",\"kty\":\"{kty}\",\"x\":\"{x}\",\"y\":\"{y}\"}}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical)); + return Base64UrlEncode(hash); + } + + private static string SerializeJwk(ECParameters parameters, bool includePrivate) + { + using var stream = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("kty", "EC"); + writer.WriteString("crv", "P-256"); + writer.WriteString("x", Base64UrlEncode(parameters.Q.X!)); + writer.WriteString("y", Base64UrlEncode(parameters.Q.Y!)); + + if (includePrivate && parameters.D is not null) + writer.WriteString("d", Base64UrlEncode(parameters.D)); + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static ECParameters DeserializeJwk(string jwk) + { + using var doc = JsonDocument.Parse(jwk); + var root = doc.RootElement; + + var kty = root.GetProperty("kty").GetString(); + var crv = root.GetProperty("crv").GetString(); + + if (kty != "EC" || crv != "P-256") + throw new CryptographicException($"Unsupported JWK key type or curve: kty={kty}, crv={crv}"); + + var parameters = new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + Q = new ECPoint + { + X = Base64UrlDecode(root.GetProperty("x").GetString()!), + Y = Base64UrlDecode(root.GetProperty("y").GetString()!) + } + }; + + if (root.TryGetProperty("d", out var dElement) && dElement.GetString() is { } dValue) + parameters.D = Base64UrlDecode(dValue); + + return parameters; + } + + private static string Base64UrlEncode(byte[] data) + { + return Convert.ToBase64String(data) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static byte[] Base64UrlDecode(string base64Url) + { + var s = base64Url.Replace('-', '+').Replace('_', '/'); + if (s.Length % 4 == 1) + throw new FormatException("Invalid base64url length."); + + switch (s.Length % 4) + { + case 2: s += "=="; break; + case 3: s += "="; break; + } + + return Convert.FromBase64String(s); + } + } +} diff --git a/src/Core/SecureFolderFS.Core.Cryptography/Jwe/JweHelper.cs b/src/Core/SecureFolderFS.Core.Cryptography/Jwe/JweHelper.cs new file mode 100644 index 000000000..c1eaee171 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/Jwe/JweHelper.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using Jose; + +namespace SecureFolderFS.Core.Cryptography.Jwe +{ + /// + /// Provides JWE encryption/decryption using ECDH-ES+A256KW key agreement with A256GCM content encryption. + /// + public static class JweHelper + { + /// + /// Encrypts a byte payload for a recipient's EC P-256 public key, producing a JWE compact serialization. + /// Includes a kid header (JWK Thumbprint, RFC 7638) binding the JWE to the recipient's key. + /// + /// The plaintext bytes to encrypt. + /// The recipient's EC P-256 public key (only the public component is used). + /// Optional additional JWE headers to include. + /// A JWE compact serialization string. + public static string Encrypt(byte[] plaintext, ECDiffieHellman recipientPublicKey, IDictionary? extraHeaders = null) + { + return JWT.EncodeBytes(plaintext, recipientPublicKey, JweAlgorithm.ECDH_ES_A256KW, JweEncryption.A256GCM, extraHeaders: extraHeaders); + } + + /// + /// Encrypts a byte payload for a recipient identified by their public key JWK string. + /// Includes a kid header (JWK Thumbprint, RFC 7638) to cryptographically bind the JWE + /// to the intended recipient's public key. The server uses this to verify the JWE is encrypted + /// for the correct user. + /// + /// The plaintext bytes to encrypt. + /// The recipient's public key as a JWK JSON string. + /// A JWE compact serialization string. + public static string Encrypt(byte[] plaintext, string recipientPublicKeyJwk) + { + using var publicKey = EcKeyHelper.ImportPublicKeyJwk(recipientPublicKeyJwk); + var kid = EcKeyHelper.ComputeJwkThumbprint(recipientPublicKeyJwk); + var headers = new Dictionary { ["kid"] = kid }; + return Encrypt(plaintext, publicKey, headers); + } + + /// + /// Decrypts a JWE compact serialization using the recipient's EC P-256 private key. + /// + /// The JWE compact serialization string to decrypt. + /// The recipient's EC P-256 private key. + /// The decrypted plaintext bytes. + public static byte[] Decrypt(string jweCompact, ECDiffieHellman recipientPrivateKey) + { + return JWT.DecodeBytes(jweCompact, recipientPrivateKey, JweAlgorithm.ECDH_ES_A256KW, JweEncryption.A256GCM); + } + + /// + /// Decrypts a JWE compact serialization using a private key loaded from raw bytes. + /// + /// The JWE compact serialization string to decrypt. + /// The recipient's private key as DER-encoded bytes. + /// The decrypted plaintext bytes. + public static byte[] Decrypt(string jweCompact, byte[] recipientPrivateKeyBytes) + { + using var privateKey = EcKeyHelper.ImportPrivateKeyBytes(recipientPrivateKeyBytes); + return Decrypt(jweCompact, privateKey); + } + + /// + /// Encrypts a vault key (DEK + MAC concatenated) for a recipient, producing a JWE. + /// + /// The 32-byte Data Encryption Key. + /// The 32-byte Message Authentication Code key. + /// The recipient's public key as a JWK JSON string. + /// A JWE compact serialization containing the encrypted vault key material. + public static string EncryptVaultKey(ReadOnlySpan dekKey, ReadOnlySpan macKey, string recipientPublicKeyJwk) + { + var combined = new byte[dekKey.Length + macKey.Length]; + try + { + dekKey.CopyTo(combined); + macKey.CopyTo(combined.AsSpan(dekKey.Length)); + return Encrypt(combined, recipientPublicKeyJwk); + } + finally + { + CryptographicOperations.ZeroMemory(combined); + } + } + + /// + /// Decrypts a JWE containing a vault key and splits it into DEK and MAC components. + /// + /// The JWE compact serialization containing the encrypted vault key. + /// The recipient's EC P-256 private key. + /// A tuple of (dekKey, macKey) byte arrays. Caller is responsible for zeroing these when done. + public static (byte[] dekKey, byte[] macKey) DecryptVaultKey(string jweCompact, ECDiffieHellman recipientPrivateKey) + { + var combined = Decrypt(jweCompact, recipientPrivateKey); + try + { + if (combined.Length != Constants.KeyTraits.DEK_KEY_LENGTH + Constants.KeyTraits.MAC_KEY_LENGTH) + throw new CryptographicException($"Decrypted vault key has unexpected length: {combined.Length}"); + + var dekKey = new byte[Constants.KeyTraits.DEK_KEY_LENGTH]; + var macKey = new byte[Constants.KeyTraits.MAC_KEY_LENGTH]; + + combined.AsSpan(0, Constants.KeyTraits.DEK_KEY_LENGTH).CopyTo(dekKey); + combined.AsSpan(Constants.KeyTraits.DEK_KEY_LENGTH, Constants.KeyTraits.MAC_KEY_LENGTH).CopyTo(macKey); + + return (dekKey, macKey); + } + finally + { + CryptographicOperations.ZeroMemory(combined); + } + } + } +} diff --git a/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs b/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs index 545539a0a..2f7027d88 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs +++ b/src/Core/SecureFolderFS.Core.Cryptography/NameCrypt/AesSivNameCrypt.cs @@ -12,11 +12,9 @@ internal sealed class AesSivNameCrypt : BaseNameCrypt public AesSivNameCrypt(KeyPair keyPair, string fileNameEncodingId) : base(fileNameEncodingId) { - _aesSiv256 = keyPair.UseKeys((dekKey, macKey) => - { - // Note: AesSiv256 requires a byte[] key. - return AesSiv256.CreateInstance(dekKey.ToArray(), macKey.ToArray()); - }); + // The spans are passed straight through so the master keys never leave SecureKey's + // protection boundary as ordinary, movable, never-zeroed heap arrays + _aesSiv256 = keyPair.UseKeys(static (dekKey, macKey) => AesSiv256.CreateInstance(dekKey, macKey)); } /// diff --git a/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj b/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj index d29745225..eb76383e9 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj +++ b/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs index 6f1474161..f1341472f 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs @@ -116,7 +116,9 @@ public virtual NtStatus GetVolumeInformation(out string volumeLabel, out FileSys volumeLabel = volumeModel.VolumeName; fileSystemName = volumeModel.FileSystemName; maximumComponentLength = Constants.Dokan.MAX_COMPONENT_LENGTH; - features = Constants.Dokan.FEATURES; + features = specifics.Options.IsReadOnly + ? Constants.Dokan.FEATURES | FileSystemFeatures.ReadOnlyVolume + : Constants.Dokan.FEATURES; return Trace(DokanResult.Success, null, info); } diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs index 487886698..02b248d3a 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs @@ -170,7 +170,7 @@ public override NtStatus CreateFile(string fileName, FileAccess access, FileShar try { - if (specifics.Options.IsReadOnly && mode.IsWriteFlag()) + if (specifics.Options.IsReadOnly && mode.IsWriteFlag(pathExists)) throw FileSystemExceptions.FileSystemReadOnly; // Materialize sidecar for the new file name if shortened @@ -441,6 +441,9 @@ public override NtStatus SetFileAttributes(string fileName, FileAttributes attri /// public override NtStatus SetFileTime(string fileName, DateTime? creationTime, DateTime? lastAccessTime, DateTime? lastWriteTime, IDokanFileInfo info) { + if (specifics.Options.IsReadOnly) + return Trace(DokanResult.AccessDenied, fileName, info); + try { if (!IsContextInvalid(info)) diff --git a/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs b/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs index 01a85a91c..9210c00cc 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs @@ -58,7 +58,7 @@ public async Task MountAsync(IFolder folder, IDisposable unlockContrac var volumeModel = new VolumeModel(specifics.Options.VolumeName, Constants.Dokan.FS_TYPE_ID); var dokanyCallbacks = new OnDeviceDokany(specifics, handlesManager, volumeModel); var dokanyWrapper = new DokanyWrapper(dokanyCallbacks); - dokanyWrapper.StartFileSystem(dokanyOptions.MountPoint); + dokanyWrapper.StartFileSystem(dokanyOptions.MountPoint, dokanyOptions.IsReadOnly); // Await a short delay before locating the folder await Task.Delay(500); diff --git a/src/Core/SecureFolderFS.Core.Dokany/DokanyWrapper.cs b/src/Core/SecureFolderFS.Core.Dokany/DokanyWrapper.cs index fae9112c0..76bf12fe9 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/DokanyWrapper.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/DokanyWrapper.cs @@ -16,12 +16,12 @@ public DokanyWrapper(BaseDokanyCallbacks dokanCallbacks) _dokanCallbacks = dokanCallbacks; } - public void StartFileSystem(string mountPoint) + public void StartFileSystem(string mountPoint, bool isReadOnly) { var dokanBuilder = new DokanInstanceBuilder(_dokan) .ConfigureOptions(opt => { - opt.Options = DokanOptions.CaseSensitive; + opt.Options = isReadOnly ? DokanOptions.CaseSensitive | DokanOptions.WriteProtection : DokanOptions.CaseSensitive; opt.UNCName = FileSystem.Constants.UNC_NAME; opt.MountPoint = mountPoint; }); diff --git a/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs b/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs index edcfc4dcc..60db44d01 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs @@ -25,7 +25,7 @@ public override ulong OpenFileHandle(string ciphertextPath, FileMode mode, FileA if (disposed) return FileSystem.Constants.INVALID_HANDLE; - if (fileSystemOptions.IsReadOnly && mode.IsWriteFlag()) + if (fileSystemOptions.IsReadOnly && mode.IsWriteFlag(File.Exists(ciphertextPath))) return FileSystem.Constants.INVALID_HANDLE; // Open ciphertext stream diff --git a/src/Core/SecureFolderFS.Core.Dokany/UnsafeNative/UnsafeNativeApis.cs b/src/Core/SecureFolderFS.Core.Dokany/UnsafeNative/UnsafeNativeApis.cs index 0c55714d4..37f40722a 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/UnsafeNative/UnsafeNativeApis.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/UnsafeNative/UnsafeNativeApis.cs @@ -25,7 +25,7 @@ public static extern bool SetFileTime( [return: MarshalAs(UnmanagedType.U8)] public static extern ulong DokanDriverVersion(); - [DllImport("Shlwapi.dll", CallingConvention = CallingConvention.StdCall, SetLastError = true)] + [DllImport("Shlwapi.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool PathMatchSpec( [In] string pszFile, diff --git a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs index 9a7d7f10f..980371149 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs @@ -1,11 +1,7 @@ -using System; -using System.IO; -using System.Linq; using System.Text; using OwlCore.Storage; using SecureFolderFS.Core.FileSystem; using SecureFolderFS.Core.FileSystem.Helpers; -using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native; @@ -15,6 +11,7 @@ using Tmds.Fuse; using Tmds.Linux; using static SecureFolderFS.Core.FUSE.UnsafeNative.UnsafeNativeApis; +using static SecureFolderFS.Core.FileSystem.Helpers.Paths.PathHelpers; using static Tmds.Linux.LibC; namespace SecureFolderFS.Core.FUSE.Callbacks @@ -39,7 +36,7 @@ public override unsafe int ChMod(ReadOnlySpan path, mode_t mode, FuseFileI if (ciphertextPath is null) return -ENOENT; - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (chmod(ciphertextPathPtr, mode) == -1) return -errno; @@ -57,7 +54,7 @@ public override unsafe int Chown(ReadOnlySpan path, uint uid, uint gid, Fu if (ciphertextPath is null) return -ENOENT; - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (chown(ciphertextPathPtr, uid, gid) == -1) return -errno; @@ -78,7 +75,7 @@ public override unsafe int Create(ReadOnlySpan path, mode_t mode, ref Fuse if ((fi.flags & O_CREAT) != 0 && (fi.flags & O_EXCL) != 0 && File.Exists(ciphertextPath)) return -EEXIST; - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { var fd = creat(ciphertextPathPtr, mode); if (fd == -1) @@ -153,7 +150,7 @@ public override unsafe int FSync(ReadOnlySpan path, bool onlyData, ref Fus if (onlyData) return 0; - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { var fd = open(ciphertextPathPtr, O_WRONLY); if (fd == -1) @@ -187,7 +184,7 @@ public override unsafe int FSyncDir(ReadOnlySpan path, bool onlyData, ref if (onlyData) return 0; - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { var fd = open(ciphertextPathPtr, O_RDONLY); if (fd == -1) @@ -212,7 +209,7 @@ public override unsafe int GetAttr(ReadOnlySpan path, ref stat stat, FuseF return -ENOENT; fixed (stat *statPtr = &stat) - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (LibC.stat(ciphertextPathPtr, statPtr) == -1) return -errno; @@ -240,7 +237,7 @@ public override unsafe int GetXAttr(ReadOnlySpan path, ReadOnlySpan return -ENOENT; fixed (byte *namePtr = name) - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { int result; if (value.Length == 0) @@ -264,7 +261,7 @@ public override unsafe int ListXAttr(ReadOnlySpan path, Span list) if (ciphertextPath is null) return -ENOENT; - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { int result; if (list.Length == 0) @@ -291,7 +288,7 @@ public override unsafe int MkDir(ReadOnlySpan path, mode_t mode) if (ciphertextPath is null) return -ENOENT; - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (mkdir(ciphertextPathPtr, mode) == -1) return -errno; @@ -405,7 +402,7 @@ public override int ReadDir(ReadOnlySpan path, ulong offset, ReadDirFlags foreach (var entry in Directory.GetFileSystemEntries(ciphertextPath)) { var ciphertextName = Path.GetFileName(entry); - if (PathHelpers.IsCoreName(ciphertextName)) + if (IsCoreName(ciphertextName)) continue; // Skip entries whose names cannot be decrypted @@ -439,7 +436,7 @@ public override unsafe int RemoveXAttr(ReadOnlySpan path, ReadOnlySpan path, ReadOnlySpan ne if (ciphertextPath is null || newCiphertextPath is null) return -ENOENT; - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) - fixed (byte *newCiphertextPathPtr = Encoding.UTF8.GetBytes(newCiphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) + fixed (byte *newCiphertextPathPtr = ToNativePath(newCiphertextPath)) { if (RenameAt2(0, ciphertextPathPtr, 0, newCiphertextPathPtr, (uint)flags) == -1) return -errno; @@ -483,10 +480,10 @@ public override unsafe int RmDir(ReadOnlySpan path) return -ENOENT; // Protect core folders from deletion - if (PathHelpers.IsCoreName(Path.GetFileName(Path.TrimEndingDirectorySeparator(ciphertextPath)))) + if (IsCoreName(Path.GetFileName(Path.TrimEndingDirectorySeparator(ciphertextPath)))) return -EACCES; - if (Directory.EnumerateFileSystemEntries(ciphertextPath).Any(x => !PathHelpers.IsCoreName(Path.GetFileName(x)))) + if (Directory.EnumerateFileSystemEntries(ciphertextPath).Any(x => !IsCoreName(Path.GetFileName(x)))) return -ENOTEMPTY; var directoryIdPath = Path.Combine(ciphertextPath, FileSystem.Constants.Names.DIRECTORY_ID_FILENAME); @@ -533,7 +530,7 @@ public override unsafe int RmDir(ReadOnlySpan path) var directoryId = File.Exists(directoryIdPath) ? File.ReadAllBytes(directoryIdPath) : null; File.Delete(directoryIdPath); - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (rmdir(ciphertextPathPtr) == -1) { @@ -568,7 +565,7 @@ public override unsafe int SetXAttr(ReadOnlySpan path, ReadOnlySpan fixed (byte *namePtr = name) fixed (void *valuePtr = value) - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (UnsafeNativeApis.SetXAttr(ciphertextPathPtr, namePtr, valuePtr, value.Length, flags) == -1) return -errno; @@ -584,7 +581,7 @@ public override unsafe int StatFS(ReadOnlySpan path, ref statvfs statfs) return -ENOENT; fixed (statvfs *statfsPtr = &statfs) - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (statvfs(ciphertextPathPtr, statfsPtr) == -1) return -errno; @@ -660,45 +657,41 @@ public override int Unlink(ReadOnlySpan path) return -EISDIR; // Protect core files from deletion - if (PathHelpers.IsCoreName(Path.GetFileName(ciphertextPath))) + if (IsCoreName(Path.GetFileName(ciphertextPath))) return -EACCES; - if (FuseOptions.IsRecycleBinEnabled()) + try { - try - { - NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.File); + // DeleteOrRecycle deletes the file immediately when the recycle bin is disabled + NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.File); - // Clean up sidecar after successful delete/recycle - NativePathHelpers.DeleteSidecarFile( - Path.GetFileName(ciphertextPath), - Path.GetDirectoryName(ciphertextPath) ?? string.Empty); + // Clean up sidecar after successful delete/recycle + NativePathHelpers.DeleteSidecarFile( + Path.GetFileName(ciphertextPath), + Path.GetDirectoryName(ciphertextPath) ?? string.Empty); - return 0; - } - catch (FileNotFoundException) - { - return -ENOENT; - } - catch (DirectoryNotFoundException) - { - return -ENOENT; - } - catch (UnauthorizedAccessException) - { - return -EACCES; - } - catch (IOException ioEx) when (ErrorHandlingHelpers.IsDiskFullException(ioEx)) - { - return -ENOSPC; - } - catch (Exception) - { - return -EIO; - } + return 0; + } + catch (FileNotFoundException) + { + return -ENOENT; + } + catch (DirectoryNotFoundException) + { + return -ENOENT; + } + catch (UnauthorizedAccessException) + { + return -EACCES; + } + catch (IOException ioEx) when (ErrorHandlingHelpers.IsDiskFullException(ioEx)) + { + return -ENOSPC; + } + catch (Exception) + { + return -EIO; } - - return 0; } public override unsafe int UpdateTimestamps(ReadOnlySpan path, ref timespec atime, ref timespec mtime, FuseFileInfoRef fiRef) @@ -711,7 +704,7 @@ public override unsafe int UpdateTimestamps(ReadOnlySpan path, ref timespe return -ENOENT; fixed (timespec *times = new[] { atime, mtime }) - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte *ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (Directory.Exists(ciphertextPath)) { diff --git a/src/Core/SecureFolderFS.Core.FUSE/FuseFileSystem.Helpers.cs b/src/Core/SecureFolderFS.Core.FUSE/FuseFileSystem.Helpers.cs index e3d63a75c..f0ff37e5e 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/FuseFileSystem.Helpers.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/FuseFileSystem.Helpers.cs @@ -1,11 +1,9 @@ -using SecureFolderFS.Storage.VirtualFileSystem; -using System.Text; -using Tmds.Fuse; +using Tmds.Fuse; using Tmds.Linux; +using static SecureFolderFS.Core.FileSystem.Helpers.Paths.PathHelpers; namespace SecureFolderFS.Core.FUSE { - /// public sealed partial class FuseFileSystem { private static string MountDirectory { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), nameof(SecureFolderFS), "mount"); @@ -14,14 +12,14 @@ public sealed partial class FuseFileSystem private static unsafe bool IsMountPoint(string directory) { stat stat = new(); - fixed (byte* pathPtr = Encoding.UTF8.GetBytes(directory)) + fixed (byte* pathPtr = ToNativePath(directory)) { if (LibC.stat(pathPtr, &stat) == -1) return false; } stat parentStat = new(); - fixed (byte* parentPathPtr = Encoding.UTF8.GetBytes(Directory.GetParent(directory)!.FullName)) + fixed (byte* parentPathPtr = ToNativePath(Directory.GetParent(directory)!.FullName)) { if (LibC.stat(parentPathPtr, &parentStat) == -1) return false; diff --git a/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs b/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs index bb7f32cb3..b2a966d57 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs @@ -27,7 +27,7 @@ public IEnumerable OpenHandles { // Return a snapshot - the live collection could be mutated // by another thread while the caller is enumerating it - lock (handles) + lock (handlesLock) return handles.Values.ToArray(); } } @@ -70,7 +70,7 @@ public override ulong OpenFileHandle(string ciphertextPath, FileMode mode, FileA var fileHandle = new FuseFileHandle(plaintextStream, access, mode, Path.GetDirectoryName(ciphertextPath)!); var handle = handlesGenerator.ThreadSafeIncrement(); - lock (handles) + lock (handlesLock) handles.TryAdd(handle, fileHandle); return handle; @@ -87,14 +87,14 @@ public override ulong OpenDirectoryHandle(string ciphertextPath) public override THandle? GetHandle(ulong handleId) where THandle : class { - lock (handles) + lock (handlesLock) return base.GetHandle(handleId); } /// public override void CloseHandle(ulong handle) { - lock (handles) + lock (handlesLock) base.CloseHandle(handle); } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs b/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs index 8e5b32627..6ea091f7f 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs @@ -1,4 +1,5 @@ using SecureFolderFS.Shared.Models; +using System.Threading; namespace SecureFolderFS.Core.FileSystem.Buffers { @@ -16,8 +17,10 @@ public sealed class HeaderBuffer : BufferHolder /// /// The header buffer is shared by all streams opened on the same file, /// so reading or creating the header must be synchronized across streams. + /// A is used instead of a monitor lock + /// so both synchronous and asynchronous code paths can participate. /// - public object SyncRoot { get; } = new(); + public SemaphoreSlim SyncRoot { get; } = new(1, 1); public HeaderBuffer(byte[] buffer) : base(buffer) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs index b0e37feec..9ac6d8d19 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs @@ -1,10 +1,13 @@ -using SecureFolderFS.Core.Cryptography.ContentCrypt; -using SecureFolderFS.Core.FileSystem.Buffers; -using SecureFolderFS.Shared.Enums; -using SecureFolderFS.Storage.VirtualFileSystem; using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Core.Cryptography.ContentCrypt; +using SecureFolderFS.Core.FileSystem.Buffers; +using SecureFolderFS.Shared.Enums; +using SecureFolderFS.Storage.VirtualFileSystem; namespace SecureFolderFS.Core.FileSystem.Chunks { @@ -18,8 +21,9 @@ public override bool FlushAvailable { get { - // Hold the cache lock for the entire operation - lock (_chunkCache) + // Hold the chunk lock for the entire operation + chunkLock.Wait(); + try { // Only chunks that were actually modified need flushing foreach (var item in _chunkCache) @@ -30,20 +34,25 @@ public override bool FlushAvailable return false; } + finally + { + chunkLock.Release(); + } } } public CachingChunkAccess(ChunkReader chunkReader, ChunkWriter chunkWriter, IContentCrypt contentCrypt, IFileSystemStatistics fileSystemStatistics) : base(chunkReader, chunkWriter, contentCrypt, fileSystemStatistics) { - _chunkCache = new(FileSystem.Constants.Caching.RECOMMENDED_SIZE_CHUNKS); + _chunkCache = new(Constants.Caching.RECOMMENDED_SIZE_CHUNKS); } /// public override int CopyFromChunk(long chunkNumber, Span destination, int offsetInChunk) { - // Hold the cache lock for the entire operation - lock (_chunkCache) + // Hold the chunk lock for the entire operation + chunkLock.Wait(); + try { // Get chunk var plaintextChunk = GetChunk(chunkNumber); @@ -59,13 +68,45 @@ public override int CopyFromChunk(long chunkNumber, Span destination, int return count; } + finally + { + chunkLock.Release(); + } + } + + /// + public override async ValueTask CopyFromChunkAsync(long chunkNumber, Memory destination, int offsetInChunk, CancellationToken cancellationToken = default) + { + // Hold the chunk lock for the entire operation + await chunkLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Get chunk + var plaintextChunk = await GetChunkAsync(chunkNumber, cancellationToken).ConfigureAwait(false); + if (plaintextChunk is null) + return -1; + + // Copy from chunk + var count = Math.Min(plaintextChunk.ActualLength - offsetInChunk, destination.Length); + if (count < 0) + return -1; + + plaintextChunk.Buffer.AsSpan(offsetInChunk, count).CopyTo(destination.Span); + + return count; + } + finally + { + chunkLock.Release(); + } } /// public override int CopyToChunk(long chunkNumber, ReadOnlySpan source, int offsetInChunk) { - // Hold the cache lock for the entire operation - lock (_chunkCache) + // Hold the chunk lock for the entire operation + chunkLock.Wait(); + try { // Get chunk var plaintextChunk = GetChunk(chunkNumber); @@ -88,13 +129,52 @@ public override int CopyToChunk(long chunkNumber, ReadOnlySpan source, int return count; } + finally + { + chunkLock.Release(); + } + } + + /// + public override async ValueTask CopyToChunkAsync(long chunkNumber, ReadOnlyMemory source, int offsetInChunk, CancellationToken cancellationToken = default) + { + // Hold the chunk lock for the entire operation + await chunkLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Get chunk + var plaintextChunk = await GetChunkAsync(chunkNumber, cancellationToken).ConfigureAwait(false); + if (plaintextChunk is null) + return -1; + + // Update state of chunk + plaintextChunk.WasModified = true; + + // Copy to chunk + var count = Math.Min(contentCrypt.ChunkPlaintextSize - offsetInChunk, source.Length); + if (count < 0) + return -1; + + var destination = plaintextChunk.Buffer.AsSpan(offsetInChunk, count); + source.Span.Slice(0, count).CopyTo(destination); + + // Update actual length + plaintextChunk.ActualLength = Math.Max(plaintextChunk.ActualLength, count + offsetInChunk); + + return count; + } + finally + { + chunkLock.Release(); + } } /// public override void SetChunkLength(long chunkNumber, int length, bool includeCurrentLength = false) { - // Hold the cache lock for the entire operation - lock (_chunkCache) + // Hold the chunk lock for the entire operation + chunkLock.Wait(); + try { // Get chunk var plaintextChunk = GetChunk(chunkNumber); @@ -108,104 +188,232 @@ public override void SetChunkLength(long chunkNumber, int length, bool includeCu // Determine whether to extend or truncate the chunk if (length < plaintextChunk.ActualLength) { - // Truncate chunk - plaintextChunk.ActualLength = Math.Min(plaintextChunk.ActualLength, length); + // Truncate chunk. The discarded bytes must actually be destroyed rather than + // just hidden behind a shorter length - a later extension of the same cached + // chunk would otherwise resurrect them and re-encrypt them into the vault + var newLength = Math.Min(plaintextChunk.ActualLength, length); + CryptographicOperations.ZeroMemory(plaintextChunk.Buffer.AsSpan(newLength, plaintextChunk.ActualLength - newLength)); + + plaintextChunk.ActualLength = newLength; } else if (plaintextChunk.ActualLength < length) { - // Extend chunk - plaintextChunk.ActualLength = Math.Min(length, contentCrypt.ChunkPlaintextSize); + // Extend chunk. The extended region must read as zeros, so any plaintext + // that a previous truncation left behind in the buffer is cleared first + var newLength = Math.Min(length, contentCrypt.ChunkPlaintextSize); + CryptographicOperations.ZeroMemory(plaintextChunk.Buffer.AsSpan(plaintextChunk.ActualLength, newLength - plaintextChunk.ActualLength)); + + plaintextChunk.ActualLength = newLength; } else return; // Ignore resizing the same length plaintextChunk.WasModified = true; } + finally + { + chunkLock.Release(); + } + } + + /// + public override void EvictChunksFrom(long fromChunkNumber) + { + // Hold the chunk lock for the entire operation + chunkLock.Wait(); + try + { + foreach (var chunkNumber in _chunkCache.Keys.Where(x => x >= fromChunkNumber).ToArray()) + { + if (!_chunkCache.Remove(chunkNumber, out var removedChunk)) + continue; + + // Discard chunks that lie beyond the new end of file instead of flushing. + // Wipe the plaintext so the truncated data does not survive in the cache + CryptographicOperations.ZeroMemory(removedChunk.Buffer); + } + } + finally + { + chunkLock.Release(); + } } /// public override void Flush() { - // Hold the cache lock for the entire operation - lock (_chunkCache) + // Hold the chunk lock for the entire operation + chunkLock.Wait(); + try + { + FlushInternal(); + } + finally + { + chunkLock.Release(); + } + } + + /// + public override async ValueTask FlushAsync(CancellationToken cancellationToken = default) + { + // Hold the chunk lock for the entire operation + await chunkLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { foreach (var item in _chunkCache) { if (item.Value.WasModified) { - chunkWriter.WriteChunk(item.Key, item.Value.Buffer.AsSpan(0, item.Value.ActualLength)); + await chunkWriter.WriteChunkAsync(item.Key, item.Value.Buffer.AsMemory(0, item.Value.ActualLength), cancellationToken).ConfigureAwait(false); // Mark the chunk as clean so subsequent flushes don't rewrite it item.Value.WasModified = false; } } } + finally + { + chunkLock.Release(); + } } - private ChunkBuffer? GetChunk(long chunkNumber) + /// The caller must hold . + private void FlushInternal() { - // Hold the cache lock for the entire operation - lock (_chunkCache) + foreach (var item in _chunkCache) { - if (!_chunkCache.TryGetValue(chunkNumber, out var plaintextChunk)) + if (item.Value.WasModified) { - // Cache miss, update stats - fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheAccess); - fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheMiss); - - // Read chunk - var buffer = new byte[contentCrypt.ChunkPlaintextSize]; - var read = chunkReader.ReadChunk(chunkNumber, buffer); - if (read < 0) - return null; - - // Create plaintext and set it to cache - plaintextChunk = new ChunkBuffer(buffer, read); - SetChunk(chunkNumber, plaintextChunk); - } - else - { - // Cache hit, update stats - fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheAccess); - fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheHit); + chunkWriter.WriteChunk(item.Key, item.Value.Buffer.AsSpan(0, item.Value.ActualLength)); + + // Mark the chunk as clean so subsequent flushes don't rewrite it + item.Value.WasModified = false; } + } + } + + /// The caller must hold . + private ChunkBuffer? GetChunk(long chunkNumber) + { + if (!_chunkCache.TryGetValue(chunkNumber, out var plaintextChunk)) + { + // Cache miss, update stats + fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheAccess); + fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheMiss); + + // Read chunk + var buffer = new byte[contentCrypt.ChunkPlaintextSize]; + var read = chunkReader.ReadChunk(chunkNumber, buffer); + if (read < 0) + return null; + + // Create plaintext and set it to cache + plaintextChunk = new ChunkBuffer(buffer, read); + SetChunk(chunkNumber, plaintextChunk); + } + else + { + // Cache hit, update stats + fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheAccess); + fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheHit); + } + + return plaintextChunk; + } + + /// The caller must hold . + private async ValueTask GetChunkAsync(long chunkNumber, CancellationToken cancellationToken) + { + if (!_chunkCache.TryGetValue(chunkNumber, out var plaintextChunk)) + { + // Cache miss, update stats + fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheAccess); + fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheMiss); - return plaintextChunk; + // Read chunk + var buffer = new byte[contentCrypt.ChunkPlaintextSize]; + var read = await chunkReader.ReadChunkAsync(chunkNumber, buffer, cancellationToken).ConfigureAwait(false); + if (read < 0) + return null; + + // Create plaintext and set it to cache + plaintextChunk = new ChunkBuffer(buffer, read); + await SetChunkAsync(chunkNumber, plaintextChunk, cancellationToken).ConfigureAwait(false); + } + else + { + // Cache hit, update stats + fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheAccess); + fileSystemStatistics.ChunkCache?.Report(CacheAccessType.CacheHit); } + + return plaintextChunk; } + /// The caller must hold . private void SetChunk(long chunkNumber, ChunkBuffer plaintextChunk) { - // Hold the cache lock for the entire operation - lock (_chunkCache) + if (_chunkCache.Count >= Constants.Caching.RECOMMENDED_SIZE_CHUNKS) { - if (_chunkCache.Count >= FileSystem.Constants.Caching.RECOMMENDED_SIZE_CHUNKS) - { - // Get chunk number to remove - var chunkNumberToRemove = _chunkCache.Keys.First(); + // Get chunk number to remove + var chunkNumberToRemove = _chunkCache.Keys.First(); - // Write chunk - if (_chunkCache.Remove(chunkNumberToRemove, out var removedChunk) && removedChunk.WasModified) + // Write chunk + if (_chunkCache.Remove(chunkNumberToRemove, out var removedChunk)) + { + if (removedChunk.WasModified) { var realRemovedChunk = removedChunk.Buffer.AsSpan(0, removedChunk.ActualLength); chunkWriter.WriteChunk(chunkNumberToRemove, realRemovedChunk); } + + // The evicted buffer holds decrypted file content, so it must not be + // abandoned to the garbage collector with the plaintext still in it + CryptographicOperations.ZeroMemory(removedChunk.Buffer); } + } - _chunkCache[chunkNumber] = plaintextChunk; + _chunkCache[chunkNumber] = plaintextChunk; + } + + /// The caller must hold . + private async ValueTask SetChunkAsync(long chunkNumber, ChunkBuffer plaintextChunk, CancellationToken cancellationToken) + { + if (_chunkCache.Count >= Constants.Caching.RECOMMENDED_SIZE_CHUNKS) + { + // Get chunk number to remove + var chunkNumberToRemove = _chunkCache.Keys.First(); + + // Write chunk + if (_chunkCache.Remove(chunkNumberToRemove, out var removedChunk)) + { + if (removedChunk.WasModified) + { + var realRemovedChunk = removedChunk.Buffer.AsMemory(0, removedChunk.ActualLength); + await chunkWriter.WriteChunkAsync(chunkNumberToRemove, realRemovedChunk, cancellationToken).ConfigureAwait(false); + } + + // The evicted buffer holds decrypted file content, so it must not be + // abandoned to the garbage collector with the plaintext still in it + CryptographicOperations.ZeroMemory(removedChunk.Buffer); + } } + + _chunkCache[chunkNumber] = plaintextChunk; } /// public override void Dispose() { - lock (_chunkCache) + chunkLock.Wait(); + try { try { // Flush outstanding modified chunks so data is not lost when // the chunk access is disposed without a prior flush - Flush(); + FlushInternal(); } catch (Exception) { @@ -213,8 +421,19 @@ public override void Dispose() } base.Dispose(); + + // Wipe the decrypted file content before the cache is detached. Locking the vault + // zeroes the DEK and MAC keys, but the plaintext those keys protected would + // otherwise be left on the managed heap for anyone reading the process memory + foreach (var item in _chunkCache) + CryptographicOperations.ZeroMemory(item.Value.Buffer); + _chunkCache.Clear(); } + finally + { + chunkLock.Release(); + } } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs index faa37ce8d..3caef91a1 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs @@ -1,6 +1,8 @@ -using System; +using System; using System.Buffers; using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; using SecureFolderFS.Core.Cryptography.ContentCrypt; using SecureFolderFS.Storage.VirtualFileSystem; @@ -23,8 +25,10 @@ internal class ChunkAccess : IDisposable /// A chunk access instance can be shared by multiple streams of the same file, /// and the reader/writer also share the position of one ciphertext stream, /// so chunk operations must not interleave. + /// A is used instead of a monitor lock + /// so both synchronous and asynchronous code paths can participate. /// - protected readonly object chunkLock = new(); + protected readonly SemaphoreSlim chunkLock = new(1, 1); /// /// Determines whether there are outstanding chunks ready to be flushed to disk. @@ -52,8 +56,9 @@ public virtual int CopyFromChunk(long chunkNumber, Span destination, int o var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize); try { - // Hold the cache lock for the entire operation - lock (chunkLock) + // Hold the chunk lock for the entire operation + chunkLock.Wait(); + try { // ArrayPool may return a larger array than requested var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize); @@ -74,6 +79,55 @@ public virtual int CopyFromChunk(long chunkNumber, Span destination, int o return count; } + finally + { + chunkLock.Release(); + } + } + finally + { + // Clear sensitive plaintext data before returning the buffer to the pool + CryptographicOperations.ZeroMemory(plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize)); + + // Return buffer + ArrayPool.Shared.Return(plaintextChunk); + } + } + + /// + public virtual async ValueTask CopyFromChunkAsync(long chunkNumber, Memory destination, int offsetInChunk, CancellationToken cancellationToken = default) + { + // Rent buffer + var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize); + try + { + // Hold the chunk lock for the entire operation + await chunkLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // ArrayPool may return a larger array than requested + var realPlaintextChunk = plaintextChunk.AsMemory(0, contentCrypt.ChunkPlaintextSize); + + // Read chunk + var read = await chunkReader.ReadChunkAsync(chunkNumber, realPlaintextChunk, cancellationToken).ConfigureAwait(false); + + // Check for any errors + if (read < 0) + return read; + + // Copy from chunk + var count = Math.Min(read - offsetInChunk, destination.Length); + if (count <= 0) + return 0; + + realPlaintextChunk.Span.Slice(offsetInChunk, count).CopyTo(destination.Span); + + return count; + } + finally + { + chunkLock.Release(); + } } finally { @@ -98,8 +152,9 @@ public virtual int CopyToChunk(long chunkNumber, ReadOnlySpan source, int var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize); try { - // Hold the cache lock for the entire operation - lock (chunkLock) + // Hold the chunk lock for the entire operation + chunkLock.Wait(); + try { // ArrayPool may return larger array than requested var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize); @@ -124,6 +179,59 @@ public virtual int CopyToChunk(long chunkNumber, ReadOnlySpan source, int return count; } + finally + { + chunkLock.Release(); + } + } + finally + { + // Clear sensitive plaintext data before returning buffer to pool + CryptographicOperations.ZeroMemory(plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize)); + + // Return buffer + ArrayPool.Shared.Return(plaintextChunk); + } + } + + /// + public virtual async ValueTask CopyToChunkAsync(long chunkNumber, ReadOnlyMemory source, int offsetInChunk, CancellationToken cancellationToken = default) + { + // Rent buffer + var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize); + try + { + // Hold the chunk lock for the entire operation + await chunkLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // ArrayPool may return larger array than requested + var realPlaintextChunk = plaintextChunk.AsMemory(0, contentCrypt.ChunkPlaintextSize); + + // Read chunk + var read = await chunkReader.ReadChunkAsync(chunkNumber, realPlaintextChunk, cancellationToken).ConfigureAwait(false); + + // Check for any errors + if (read < 0) + return read; + + // Copy to chunk + var count = Math.Min(contentCrypt.ChunkPlaintextSize - offsetInChunk, source.Length); + if (count <= 0) + return 0; + + var destination = realPlaintextChunk.Slice(offsetInChunk, count); + source.Span.Slice(0, count).CopyTo(destination.Span); + + // Write to chunk + await chunkWriter.WriteChunkAsync(chunkNumber, destination, cancellationToken).ConfigureAwait(false); + + return count; + } + finally + { + chunkLock.Release(); + } } finally { @@ -147,8 +255,9 @@ public virtual void SetChunkLength(long chunkNumber, int length, bool includeCur var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize); try { - // Hold the cache lock for the entire operation - lock (chunkLock) + // Hold the chunk lock for the entire operation + chunkLock.Wait(); + try { // ArrayPool may return larger array than requested var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize); @@ -186,6 +295,10 @@ public virtual void SetChunkLength(long chunkNumber, int length, bool includeCur // Save newly modified chunk chunkWriter.WriteChunk(chunkNumber, newPlaintextChunk); } + finally + { + chunkLock.Release(); + } } finally { @@ -197,6 +310,18 @@ public virtual void SetChunkLength(long chunkNumber, int length, bool includeCur } } + /// + /// Discards any cached chunk whose number is greater than or equal to . + /// + /// The first chunk number to discard. + /// + /// Used when a file is truncated, so that chunks past the new end of file are not + /// served from the cache and are not flushed back over the shortened file. + /// + public virtual void EvictChunksFrom(long fromChunkNumber) + { + } + /// /// Flushes outstanding chunks to disk. /// @@ -204,6 +329,12 @@ public virtual void Flush() { } + /// + public virtual ValueTask FlushAsync(CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } + /// public virtual void Dispose() { diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs index 53eb7cfb7..60b852776 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs @@ -2,6 +2,8 @@ using System.Buffers; using System.IO; using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; @@ -60,7 +62,15 @@ public int ReadChunk(long chunkNumber, Span plaintextChunk) return 0; // Read from the stream at the correct chunk - var read = _ciphertextStream.Read(realCiphertextChunk); + var read = 0; + while (read < ciphertextSize) + { + var bytesRead = _ciphertextStream.Read(realCiphertextChunk.Slice(read)); + if (bytesRead <= 0) + break; + + read += bytesRead; + } // Check for the end of the file if (read == Constants.FILE_EOF) @@ -68,12 +78,13 @@ public int ReadChunk(long chunkNumber, Span plaintextChunk) _fileSystemStatistics.BytesRead?.Report(read); - // Get reserved part for ciphertext chunk - var chunkReservedSize = Math.Min(read, _security.ContentCrypt.ChunkFirstReservedSize); - var chunkReserved = realCiphertextChunk.Slice(0, chunkReservedSize); - - // Check if the reserved part is all zeros, in which case the decryption will be skipped (the chunk was extended) - if (chunkReservedSize > 0 && SpanExtensions.IsAllZeros(chunkReserved)) + // A legitimately sparse (SetLength-extended) or repaired chunk is zero-filled across its + // ENTIRE length, so only a fully-zero chunk may skip authentication. Checking just the + // reserved nonce would let an attacker with ciphertext write access zero those few bytes + // to force any real chunk to decrypt as zeros with its MAC/AEAD tag never verified. + // Requiring the whole chunk to be zero sends any partial tamper down the authenticated + // path below, where the failed tag surfaces as an integrity error (-1). + if (read > 0 && SpanExtensions.IsAllZeros(realCiphertextChunk.Slice(0, read))) { plaintextChunk.Clear(); return read - (ciphertextSize - plaintextSize); @@ -103,5 +114,86 @@ public int ReadChunk(long chunkNumber, Span plaintextChunk) ArrayPool.Shared.Return(ciphertextChunk); } } + + /// + public async ValueTask ReadChunkAsync(long chunkNumber, Memory plaintextChunk, CancellationToken cancellationToken = default) + { + // Calculate sizes + var ciphertextSize = _security.ContentCrypt.ChunkCiphertextSize; + var plaintextSize = _security.ContentCrypt.ChunkPlaintextSize; + var ciphertextPosition = _security.HeaderCrypt.HeaderCiphertextSize + (chunkNumber * ciphertextSize); + + // Rent buffer + var ciphertextChunk = ArrayPool.Shared.Rent(ciphertextSize); + try + { + // ArrayPool may return a larger array than requested + var realCiphertextChunk = ciphertextChunk.AsMemory(0, ciphertextSize); + + // Check position bounds + if (_ciphertextStream.CanSeek && _ciphertextStream.Length < ciphertextPosition) + return 0; + + // Set the correct stream position + if (!await _ciphertextStream.TrySetPositionOrAdvanceAsync(ciphertextPosition, cancellationToken).ConfigureAwait(false)) + return 0; + + // Return early if the stream is at the EOF position + if (_ciphertextStream.IsEndOfStream()) + return 0; + + // Read from the stream at the correct chunk + var read = 0; + while (read < ciphertextSize) + { + var bytesRead = await _ciphertextStream.ReadAsync(realCiphertextChunk.Slice(read), cancellationToken).ConfigureAwait(false); + if (bytesRead <= 0) + break; + + read += bytesRead; + } + + // Check for the end of the file + if (read == Constants.FILE_EOF) + return 0; + + _fileSystemStatistics.BytesRead?.Report(read); + + // A legitimately sparse (SetLength-extended) or repaired chunk is zero-filled across its + // ENTIRE length, so only a fully-zero chunk may skip authentication. Checking just the + // reserved nonce would let an attacker with ciphertext write access zero those few bytes + // to force any real chunk to decrypt as zeros with its MAC/AEAD tag never verified. + // Requiring the whole chunk to be zero sends any partial tamper down the authenticated + // path below, where the failed tag surfaces as an integrity error (-1). + if (read > 0 && SpanExtensions.IsAllZeros(realCiphertextChunk.Span.Slice(0, read))) + { + plaintextChunk.Span.Clear(); + return read - (ciphertextSize - plaintextSize); + } + + // Decrypt + var result = _security.ContentCrypt.DecryptChunk( + realCiphertextChunk.Span.Slice(0, read), + chunkNumber, + _fileHeader, + plaintextChunk.Span); + + _fileSystemStatistics.BytesDecrypted?.Report(read); + + // Check if the chunk is authentic + if (!result) + return -1; + + return read - (ciphertextSize - plaintextSize); + } + finally + { + // Clear ciphertext data before returning buffer to pool + CryptographicOperations.ZeroMemory(ciphertextChunk.AsSpan(0, ciphertextSize)); + + // Return buffer + ArrayPool.Shared.Return(ciphertextChunk); + } + } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs index 785b42fa5..129b11050 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs @@ -2,6 +2,8 @@ using System.Buffers; using System.IO; using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Buffers; using SecureFolderFS.Shared.Extensions; @@ -80,5 +82,55 @@ public void WriteChunk(long chunkNumber, ReadOnlySpan plaintextChunk) ArrayPool.Shared.Return(ciphertextChunk); } } + + /// + public async ValueTask WriteChunkAsync(long chunkNumber, ReadOnlyMemory plaintextChunk, CancellationToken cancellationToken = default) + { + // Calculate size of ciphertext + var ciphertextSize = Math.Min(plaintextChunk.Length + (_security.ContentCrypt.ChunkCiphertextSize - _security.ContentCrypt.ChunkPlaintextSize), _security.ContentCrypt.ChunkCiphertextSize); + + // Calculate position in ciphertext stream + var streamPosition = _security.HeaderCrypt.HeaderCiphertextSize + chunkNumber * _security.ContentCrypt.ChunkCiphertextSize; + + // Rent buffer + var ciphertextChunk = ArrayPool.Shared.Rent(ciphertextSize); + try + { + // ArrayPool may return a larger array than requested + var realCiphertextChunk = ciphertextChunk.AsMemory(0, ciphertextSize); + + // Encrypt + _security.ContentCrypt.EncryptChunk( + plaintextChunk.Span, + chunkNumber, + _fileHeader, + realCiphertextChunk.Span); + + _fileSystemStatistics.BytesEncrypted?.Report(plaintextChunk.Length); + + // Extend the stream when the chunk starts beyond the current end. + // The zero-filled region decrypts as valid zero chunks, so out-of-order + // chunk writes must not be dropped as that would silently lose data + if (_ciphertextStream.CanSeek && streamPosition > _ciphertextStream.Length) + _ciphertextStream.SetLength(streamPosition); + + // Set the correct stream position + if (!await _ciphertextStream.TrySetPositionOrAdvanceAsync(streamPosition, cancellationToken).ConfigureAwait(false)) + throw new IOException($"The stream position could not be set to the chunk at {streamPosition}."); + + // Write to stream at the correct chunk + await _ciphertextStream.WriteAsync(realCiphertextChunk, cancellationToken).ConfigureAwait(false); + + _fileSystemStatistics.BytesWritten?.Report(realCiphertextChunk.Length); + } + finally + { + // Clear ciphertext data before returning buffer to pool + CryptographicOperations.ZeroMemory(ciphertextChunk.AsSpan(0, ciphertextSize)); + + // Return buffer + ArrayPool.Shared.Return(ciphertextChunk); + } + } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs b/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs index ae6bd0e36..94c22ec84 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs @@ -33,6 +33,7 @@ public static class Names 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"; + public const string WIDGET_DATA_FILE_EXTENSION = ".sffsw"; } public static class Caching diff --git a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs index 4565b6d12..cff782128 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Security.Cryptography; using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Buffers; using SecureFolderFS.Core.FileSystem.Chunks; @@ -116,6 +117,10 @@ public void Dispose() stream.Dispose(); } _openedStreams.Clear(); + + // Wipe the header content key from memory to avoid leaving it on the heap after the file is closed + CryptographicOperations.ZeroMemory(HeaderBuffer.Buffer); + HeaderBuffer.IsHeaderReady = false; } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs index 05ee2a388..f47b209a8 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; namespace SecureFolderFS.Core.FileSystem.CryptFiles { @@ -103,11 +104,19 @@ private void NotifyClosed(string ciphertextPath) /// public void Dispose() { + // Snapshot under the lock, then dispose outside it. OpenCryptFile.Dispose blocks on that + // file's stream lock, and a stream closing concurrently holds its stream lock while calling + // back into NotifyClosed here, so disposing while holding this lock is a lock-order inversion. + // It deadlocks the vault-lock path, which then never reaches Security.Dispose + // and leaves the DEK and MAC keys resident in the memory of a hung process + OpenCryptFile[] cryptFiles; lock (_openCryptFiles) { - _openCryptFiles.Values.DisposeAll(); + cryptFiles = _openCryptFiles.Values.ToArray(); _openCryptFiles.Clear(); } + + cryptFiles.DisposeAll(); } } } \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs index 38df1d3ae..38d0fba81 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs @@ -1,6 +1,9 @@ using System; +using System.Buffers; using System.IO; using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using SecureFolderFS.Core.Cryptography.HeaderCrypt; using SecureFolderFS.Core.FileSystem.Buffers; using SecureFolderFS.Storage.VirtualFileSystem; @@ -19,7 +22,8 @@ public static bool ReadHeader(this HeaderBuffer headerBuffer, Stream ciphertextS throw FileSystemExceptions.StreamNotReadable; // The header buffer is shared by all streams of the same file, so a lock is needed - lock (headerBuffer.SyncRoot) + headerBuffer.SyncRoot.Wait(); + try { // Re-check after lock if (headerBuffer.IsHeaderReady) @@ -54,6 +58,71 @@ public static bool ReadHeader(this HeaderBuffer headerBuffer, Stream ciphertextS return headerBuffer.IsHeaderReady; } + finally + { + headerBuffer.SyncRoot.Release(); + } + } + + /// + public static async ValueTask ReadHeaderAsync(this HeaderBuffer headerBuffer, Stream ciphertextStream, IHeaderCrypt headerCrypt, CancellationToken cancellationToken = default) + { + if (headerBuffer.IsHeaderReady) + return true; + + if (!ciphertextStream.CanRead) + throw FileSystemExceptions.StreamNotReadable; + + // The header buffer is shared by all streams of the same file, so a lock is needed + await headerBuffer.SyncRoot.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Re-check after lock + if (headerBuffer.IsHeaderReady) + return true; + + // Rent ciphertext header buffer (asynchronous methods cannot use stackalloc) + var ciphertextHeader = ArrayPool.Shared.Rent(headerCrypt.HeaderCiphertextSize); + try + { + // ArrayPool may return a larger array than requested + var realCiphertextHeader = ciphertextHeader.AsMemory(0, headerCrypt.HeaderCiphertextSize); + + // Read header + int read; + if (ciphertextStream.CanSeek && ciphertextStream.Position != 0L) + { + var ciphertextPosition = ciphertextStream.Position; + ciphertextStream.Position = 0L; + + read = await ciphertextStream.ReadAsync(realCiphertextHeader, cancellationToken).ConfigureAwait(false); + ciphertextStream.Position = ciphertextPosition; + } + else + { + // Non-seekable streams must be at position 0 - header is always read first sequentially. + // There is no way to rewind, so we simply read and continue. + read = await ciphertextStream.ReadAsync(realCiphertextHeader, cancellationToken).ConfigureAwait(false); + } + + // Check if the read amount is correct + if (read < realCiphertextHeader.Length) + return false; + + // Decrypt header + headerBuffer.IsHeaderReady = headerCrypt.DecryptHeader(realCiphertextHeader.Span, headerBuffer); + + return headerBuffer.IsHeaderReady; + } + finally + { + ArrayPool.Shared.Return(ciphertextHeader); + } + } + finally + { + headerBuffer.SyncRoot.Release(); + } } } -} \ No newline at end of file +} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/StreamingExtensions.cs b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/StreamingExtensions.cs index 8ee8b6c19..213972809 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/StreamingExtensions.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/StreamingExtensions.cs @@ -19,9 +19,25 @@ public static class StreamingExtensions } } + /// + /// Determines whether may create or truncate a file, and therefore + /// must be refused on read-only file systems. + /// public static bool IsWriteFlag(this FileMode mode) { - return mode is FileMode.Create or FileMode.CreateNew or FileMode.Append or FileMode.Truncate; + return mode is FileMode.Create or FileMode.CreateNew or FileMode.Append or FileMode.Truncate or FileMode.OpenOrCreate; + } + + /// + /// Whether the target already exists in the ciphertext store. + public static bool IsWriteFlag(this FileMode mode, bool pathExists) + { + // OpenOrCreate only mutates the store when the file is not already there; + // on an existing file it is an ordinary open and stays allowed while read-only + if (mode == FileMode.OpenOrCreate) + return !pathExists; + + return mode.IsWriteFlag(); } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.FileContent.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.FileContent.cs index c5af889cd..5260a7598 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.FileContent.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.FileContent.cs @@ -2,6 +2,7 @@ using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Buffers; using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage.Extensions; using System; @@ -130,17 +131,11 @@ public static async Task ValidateFileContentsAsync( if (read == 0) break; - // Check if chunk first bytes are all zeros (extended chunk, skip validation) - var chunkReservedSize = Math.Min(read, security.ContentCrypt.ChunkFirstReservedSize); - var isAllZeros = true; - for (var i = 0; i < chunkReservedSize; i++) - { - if (ciphertextChunk[i] != 0) - { - isAllZeros = false; - break; - } - } + // Only a fully zero-filled chunk is a legitimate sparse/repaired hole (see ChunkReader). + // Checking just the reserved nonce would let a tampered chunk - nonce zeroed but + // ciphertext left intact - pass as a valid hole and be reported as clean; requiring the + // whole chunk to be zero sends any partial tamper through decryption below, where a failed tag marks the chunk corrupted. + var isAllZeros = SpanExtensions.IsAllZeros(ciphertextChunk.AsSpan(0, read)); if (!isAllZeros) { diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs index 3a656bdd8..a9e0ffefc 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Runtime.CompilerServices; +using System.Text; namespace SecureFolderFS.Core.FileSystem.Helpers.Paths { @@ -12,6 +13,7 @@ public static bool IsCoreName(string itemName) { return itemName.EndsWith(Constants.Names.SIDECAR_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase) || + itemName.EndsWith(Constants.Names.WIDGET_DATA_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase) || itemName.Contains(Constants.Names.DIRECTORY_ID_FILENAME, StringComparison.OrdinalIgnoreCase) || itemName.Contains(Constants.Names.RECYCLE_BIN_NAME, StringComparison.OrdinalIgnoreCase) || itemName.Contains(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, StringComparison.OrdinalIgnoreCase); @@ -40,5 +42,23 @@ public static string EnsureNoLeadingPathSeparator(string path) return null; } + + /// + /// Encodes as NUL-terminated UTF-8 for libc APIs expecting a C string. + /// + /// + /// allocates exactly as many bytes as the encoding needs + /// and appends no terminator. Handing that array to a byte* binding makes libc scan past + /// the end of it into whatever follows on the GC heap, and act on a path with arbitrary trailing + /// bytes, so paths must be terminated explicitly. + /// + public static byte[] ToNativePath(string value) + { + var buffer = new byte[Encoding.UTF8.GetByteCount(value) + 1]; + Encoding.UTF8.GetBytes(value, buffer); + + // The trailing byte is left zero + return buffer; + } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs index b276ea4bf..bbd84fad3 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs @@ -271,6 +271,10 @@ private static long FoldDescendantEntries(string recycleBinPath, string recycled if (dataModel is not { Name: not null, ParentId: not null, DirectoryId: { Length: Constants.DIRECTORY_ID_SIZE } childDirectoryId }) return; + // Check if the data model is authentic + if (!dataModel.VerifyMac(Path.GetFileNameWithoutExtension(configurationPath), specifics.Security)) + return; + // Lineage check: the entry must have been deleted out of this exact folder incarnation if (!childDirectoryId.AsSpan().SequenceEqual(folderDirectoryId)) return; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs index 3abf7c499..1b5c91289 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs @@ -279,31 +279,31 @@ public virtual async Task MoveFromAsync(IChildFile fileToMove, IModi where TStorable : class, IStorableChild { var parentFolder = await item.GetParentAsync(cancellationToken); - if (parentFolder is null || parentFolder.Id == Path.DirectorySeparatorChar.ToString()) - { - // We're at the root - parentFolder ??= item as IFolder; - if (parentFolder is not IWrapper folderWrapper) - return null; - if (folderWrapper.GetWrapperAt() is not { Inner: var ciphertextRoot }) + // The item is the vault root itself (it has no parent): its own ciphertext is the answer. + if (parentFolder is null) + { + if (item is not IWrapper rootWrapper) return null; - if (parentFolder.Id == Path.DirectorySeparatorChar.ToString() || parentFolder.Id == specifics.ContentFolder.Id) - return ciphertextRoot as TStorable; - - var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(item.Name, ciphertextRoot, specifics, cancellationToken); - return await ciphertextRoot.TryGetFirstByNameAsync(ciphertextName, cancellationToken) as TStorable; + return rootWrapper.GetWrapperAt() is { Inner: var ciphertextRoot } + ? ciphertextRoot as TStorable + : null; } + // Otherwise resolve the item by name inside its parent's ciphertext folder. This covers + // items directly under the root and items nested deeper alike: the parent supplies the + // Directory ID that the name is encrypted against. (A previous special-case returned the + // root's own ciphertext for any child of the root, which resolved every top-level item to + // the wrong folder and broke moves/copies out of top-level folders.) if (parentFolder is not IWrapper parentFolderWrapper) return null; if (parentFolderWrapper.GetWrapperAt() is not { Inner: var ciphertextParent }) return null; - var ciphertextName2 = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(item.Name, ciphertextParent, specifics, cancellationToken); - return await ciphertextParent.TryGetFirstByNameAsync(ciphertextName2, cancellationToken) as TStorable; + var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(item.Name, ciphertextParent, specifics, cancellationToken); + return await ciphertextParent.TryGetFirstByNameAsync(ciphertextName, cancellationToken) as TStorable; } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs index 5a4a4d0c4..2a3e32216 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs @@ -3,12 +3,13 @@ using System.IO; using System.Runtime.CompilerServices; using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Buffers; using SecureFolderFS.Core.FileSystem.Chunks; using SecureFolderFS.Core.FileSystem.Extensions; using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.VirtualFileSystem; @@ -76,6 +77,18 @@ public override void Write(byte[] buffer, int offset, int count) Write(buffer.AsSpan(offset, count)); } + /// + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + /// + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + /// public override int Read(Span buffer) { @@ -85,12 +98,12 @@ public override int Read(Span buffer) if (buffer.IsEmpty) return 0; - // For seekable streams, perform EOF checks up front + // For seekable streams, perform EOF checks up front. + // EOF is decided by the plaintext position against the plaintext Length, never by the + // ciphertext stream's own position. Chunks are pulled into the cache ahead of the + // caller, so the inner stream reaches its end while plaintext is still unconsumed. if (Inner.CanSeek) { - if (Inner.IsEndOfStream()) - return Constants.FILE_EOF; - if (Inner.Length < _security.HeaderCrypt.HeaderCiphertextSize) return Constants.FILE_EOF; @@ -128,6 +141,58 @@ public override int Read(Span buffer) return positionInBuffer == 0 ? Constants.FILE_EOF : positionInBuffer; } + /// + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (!CanRead) + throw FileSystemExceptions.StreamNotReadable; + + if (buffer.IsEmpty) + return 0; + + // For seekable streams, perform EOF checks up front. + // EOF is decided by the plaintext position against the plaintext Length, never by the + // ciphertext stream's own position. Chunks are pulled into the cache ahead of the + // caller, so the inner stream reaches its end while plaintext is still unconsumed. + if (Inner.CanSeek) + { + if (Inner.Length < _security.HeaderCrypt.HeaderCiphertextSize) + return Constants.FILE_EOF; + + if (Length - Position <= 0L) + return Constants.FILE_EOF; + } + + // Read header if is not ready + if (!await _headerBuffer.ReadHeaderAsync(Inner, _security.HeaderCrypt, cancellationToken).ConfigureAwait(false)) + throw new CryptographicException("Could not read header."); + + var positionInBuffer = 0; + var plaintextChunkSize = _security.ContentCrypt.ChunkPlaintextSize; + var adjustedBuffer = Inner.CanSeek + ? buffer.Slice(0, (int)Math.Min(buffer.Length, Length - Position)) + : buffer; + + while (positionInBuffer < adjustedBuffer.Length) + { + var readPosition = Position + positionInBuffer; + var chunkNumber = readPosition / plaintextChunkSize; + var offsetInChunk = (int)(readPosition % plaintextChunkSize); + + var copied = await _chunkAccess.CopyFromChunkAsync(chunkNumber, adjustedBuffer.Slice(positionInBuffer), offsetInChunk, cancellationToken).ConfigureAwait(false); + if (copied < 0) + throw new CryptographicException(); + + if (copied == 0) + break; + + positionInBuffer += copied; + } + + _position += positionInBuffer; + return positionInBuffer == 0 ? Constants.FILE_EOF : positionInBuffer; + } + /// [SkipLocalsInit] public override void Write(ReadOnlySpan buffer) @@ -172,6 +237,49 @@ public override void Write(ReadOnlySpan buffer) WriteInternal(buffer, Position); } + /// + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (!CanWrite) + throw FileSystemExceptions.StreamReadOnly; + + // Don't initiate writing if the buffer is empty + if (buffer.IsEmpty) + return; + + if (CanSeek && Position > Length) + { + // Fill the gap between the current length and the write position with zeros. + // Zeros keep sparse semantics consistent with SetLength-based extension, + // where a zeroed region also reads back as zeros. + var writePosition = Position; + var gapBuffer = ArrayPool.Shared.Rent(_security.ContentCrypt.ChunkPlaintextSize); + try + { + Array.Clear(gapBuffer, 0, gapBuffer.Length); + + var gapPosition = Length; + while (gapPosition < writePosition) + { + var gapPart = (int)Math.Min(writePosition - gapPosition, gapBuffer.Length); + await WriteInternalAsync(gapBuffer.AsMemory(0, gapPart), gapPosition, cancellationToken).ConfigureAwait(false); + gapPosition += gapPart; + } + } + finally + { + ArrayPool.Shared.Return(gapBuffer); + } + + // WriteInternal advances the position by the amount written - restore + // it so the actual contents are written at the requested position + _position = writePosition; + } + + // Write contents + await WriteInternalAsync(buffer, Position, cancellationToken).ConfigureAwait(false); + } + /// public override void SetLength(long value) { @@ -194,12 +302,14 @@ public override void SetLength(long value) // Determine whether to extend or truncate the file if (value < Length) { + var lastChunkNumber = value / plaintextChunkSize; var remainingSize = (int)(value % plaintextChunkSize); if (remainingSize > 0) - { - var lastChunkNumber = value / plaintextChunkSize; _chunkAccess.SetChunkLength(lastChunkNumber, remainingSize); - } + + // Drop cached chunks past the new end of file. They hold plaintext the user just + // deleted, and leaving them cached would both serve that data back on a later read and flush it over the truncated file + _chunkAccess.EvictChunksFrom(remainingSize > 0 ? lastChunkNumber + 1 : lastChunkNumber); // Update position to fit within new length _position = Math.Min(value, _position); @@ -271,6 +381,21 @@ public override void Close() } } + /// + public override async ValueTask DisposeAsync() + { + try + { + if (CanWrite) + await FlushAsync().ConfigureAwait(false); + } + finally + { + // Calls Dispose (and in turn Close) which notifies about the closed stream + await base.DisposeAsync().ConfigureAwait(false); + } + } + /// public override void Flush() { @@ -285,6 +410,20 @@ public override void Flush() } } + /// + public override async Task FlushAsync(CancellationToken cancellationToken) + { + if (!CanWrite) + throw FileSystemExceptions.StreamReadOnly; + + // Only flush when there's a need to + if (_chunkAccess.FlushAvailable) + { + await TryWriteHeaderAsync(cancellationToken).ConfigureAwait(false); + await _chunkAccess.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + private void WriteInternal(ReadOnlySpan buffer, long position) { if (!TryWriteHeader() && !_headerBuffer.ReadHeader(Inner, _security.HeaderCrypt)) @@ -323,6 +462,45 @@ private void WriteInternal(ReadOnlySpan buffer, long position) File.SetLastWriteTime(fileStream.SafeFileHandle, DateTime.Now); } + private async ValueTask WriteInternalAsync(ReadOnlyMemory buffer, long position, CancellationToken cancellationToken) + { + if (!await TryWriteHeaderAsync(cancellationToken).ConfigureAwait(false) && !await _headerBuffer.ReadHeaderAsync(Inner, _security.HeaderCrypt, cancellationToken).ConfigureAwait(false)) + throw new CryptographicException("Could not write nor read the header."); + + var plaintextChunkSize = _security.ContentCrypt.ChunkPlaintextSize; + var written = 0; + var positionInBuffer = 0; + + while (positionInBuffer < buffer.Length) + { + var currentPosition = position + written; + var chunkNumber = currentPosition / plaintextChunkSize; + var offsetInChunk = (int)(currentPosition % plaintextChunkSize); + var length = Math.Min(buffer.Length - positionInBuffer, plaintextChunkSize - offsetInChunk); + var copy = await _chunkAccess.CopyToChunkAsync( + chunkNumber, + buffer.Slice(positionInBuffer), + (offsetInChunk == 0 && length == plaintextChunkSize) ? 0 : offsetInChunk, + cancellationToken).ConfigureAwait(false); + + if (copy < 0) + throw new CryptographicException(); + + positionInBuffer += copy; + written += length; + } + + // Update length after writing + _length = Math.Max(position + written, Length); + + // Update position after writing + _position += written; + + // Update last write time + if (Inner is FileStream fileStream) + File.SetLastWriteTime(fileStream.SafeFileHandle, DateTime.Now); + } + [SkipLocalsInit] private bool TryWriteHeader() { @@ -331,7 +509,8 @@ private bool TryWriteHeader() // The header buffer is shared by all streams of the same file, // so lock on the buffer's synchronization root - lock (_headerBuffer.SyncRoot) + _headerBuffer.SyncRoot.Wait(); + try { // Re-check after lock if (_headerBuffer.IsHeaderReady) @@ -366,6 +545,68 @@ private bool TryWriteHeader() return true; } + finally + { + _headerBuffer.SyncRoot.Release(); + } + } + + private async ValueTask TryWriteHeaderAsync(CancellationToken cancellationToken) + { + if (_headerBuffer.IsHeaderReady) + return true; + + // The header buffer is shared by all streams of the same file, + // so lock on the buffer's synchronization root + await _headerBuffer.SyncRoot.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Re-check after lock + if (_headerBuffer.IsHeaderReady) + return true; + + // Check if there is data already written only when we can seek + if (Inner.Length > 0L) + return false; + + // Rent ciphertext header buffer (asynchronous methods cannot use stackalloc) + var ciphertextHeader = ArrayPool.Shared.Rent(_security.HeaderCrypt.HeaderCiphertextSize); + try + { + // ArrayPool may return a larger array than requested + var realCiphertextHeader = ciphertextHeader.AsMemory(0, _security.HeaderCrypt.HeaderCiphertextSize); + + // Get and encrypt the header + _security.HeaderCrypt.CreateHeader(_headerBuffer); + _security.HeaderCrypt.EncryptHeader(_headerBuffer, realCiphertextHeader.Span); + + // Write header + if (CanSeek) + { + var savedPosition = Inner.Position; + Inner.Position = 0L; + await Inner.WriteAsync(realCiphertextHeader, cancellationToken).ConfigureAwait(false); + Inner.Position = savedPosition + realCiphertextHeader.Length; + } + else + { + await Inner.WriteAsync(realCiphertextHeader, cancellationToken).ConfigureAwait(false); + } + + // Make sure we save the header state + _headerBuffer.IsHeaderReady = true; + + return true; + } + finally + { + ArrayPool.Shared.Return(ciphertextHeader); + } + } + finally + { + _headerBuffer.SyncRoot.Release(); + } } private long AlignToChunkStartPosition(long plaintextPosition) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs index 235aa918f..52071b0ed 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs @@ -1,5 +1,4 @@ using OwlCore.Storage; -using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Exceptions; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Shared.ComponentModel; @@ -71,16 +70,7 @@ protected async Task ValidateNameResultAsync(IStorableChild storable, C if (!string.IsNullOrEmpty(decryptedName)) return decryptedName; - // A shortened file (.sffsn) that couldn't be decrypted means its sidecar is missing. - // Report this as an invalid name so the health system can offer to generate a new one. - if (storable.Name.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) - return null; - - // We want to suppress failures that might be raised when the Directory ID file is not found. - // This case should be already handled in the folder validator - - // Return an empty string to prevent raising exceptions due to the name being null - return string.Empty; + return null; } } } diff --git a/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/OnDeviceMacFuse.cs b/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/OnDeviceMacFuse.cs index c2e7da667..5bd0841a7 100644 --- a/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/OnDeviceMacFuse.cs +++ b/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/OnDeviceMacFuse.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using FuseSharp; using FuseSharp.Native; using OwlCore.Storage; @@ -10,6 +10,7 @@ using SecureFolderFS.Core.MacFuse.OpenHandles; using SecureFolderFS.Storage.Extensions; using static FuseSharp.Native.LibC; +using static SecureFolderFS.Core.FileSystem.Helpers.Paths.PathHelpers; namespace SecureFolderFS.Core.MacFuse.Callbacks { @@ -53,7 +54,7 @@ public override unsafe int Chown(ReadOnlySpan path, uint uid, uint gid, Fu if (ciphertextPath is null) return -ENOENT; - fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte* ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (chown(ciphertextPathPtr, uid, gid) == -1) return -errno; @@ -209,8 +210,12 @@ public override int GetAttr(ReadOnlySpan path, ref Stat stat, FuseFileInfo } else if (isFile) { + // A size query by path must still see data that open handles have not flushed yet. The + // kernel resizes its page cache to the size reported here, so reporting the on-disk + // size mid-write drops written pages, which then read back as zeros var ciphertextSize = ((FileInfo)info).Length; - stat.st_size = Math.Max(0, specifics.Security.ContentCrypt.CalculatePlaintextSize(ciphertextSize - specifics.Security.HeaderCrypt.HeaderCiphertextSize)); + stat.st_size = GetOpenLength(ciphertextPath) + ?? Math.Max(0, specifics.Security.ContentCrypt.CalculatePlaintextSize(ciphertextSize - specifics.Security.HeaderCrypt.HeaderCiphertextSize)); } stat.st_blksize = 4096; @@ -231,7 +236,7 @@ public override unsafe int GetXAttr(ReadOnlySpan path, ReadOnlySpan return -ENOENT; fixed (byte* namePtr = NullTerminate(name)) - fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte* ciphertextPathPtr = ToNativePath(ciphertextPath)) { nint result; if (value.Length == 0) @@ -255,7 +260,7 @@ public override unsafe int ListXAttr(ReadOnlySpan path, Span list) if (ciphertextPath is null) return -ENOENT; - fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte* ciphertextPathPtr = ToNativePath(ciphertextPath)) { nint result; if (list.Length == 0) @@ -430,7 +435,7 @@ public override unsafe int RemoveXAttr(ReadOnlySpan path, ReadOnlySpan path, ReadOnlySpan ne if (ciphertextPath is null || newCiphertextPath is null) return -ENOENT; - fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) - fixed (byte* newCiphertextPathPtr = Encoding.UTF8.GetBytes(newCiphertextPath)) + fixed (byte* ciphertextPathPtr = ToNativePath(ciphertextPath)) + fixed (byte* newCiphertextPathPtr = ToNativePath(newCiphertextPath)) { var result = flags == 0u ? rename(ciphertextPathPtr, newCiphertextPathPtr) @@ -460,6 +465,18 @@ public override unsafe int Rename(ReadOnlySpan path, ReadOnlySpan ne return -errno; } + // Open handles follow the file, so size queries under the new name still see their unflushed data + foreach (var handle in handlesManager.OpenHandles) + { + if (handle is not MacFuseFileHandle macFuseFileHandle) + continue; + + if (macFuseFileHandle.CiphertextPath.Equals(ciphertextPath, StringComparison.Ordinal)) + macFuseFileHandle.CiphertextPath = newCiphertextPath; + else if (macFuseFileHandle.CiphertextPath.StartsWith(ciphertextPath + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + macFuseFileHandle.CiphertextPath = newCiphertextPath + macFuseFileHandle.CiphertextPath[ciphertextPath.Length..]; + } + return 0; } @@ -490,8 +507,7 @@ public override int RmDir(ReadOnlySpan path) else Directory.Delete(ciphertextPath, recursive: true); // Recursive because we want to delete the Directory ID file - // Delete and remove Directory ID - File.Delete(directoryIdPath); + // Remove Directory ID from cache specifics.DirectoryIdCache.CacheRemove(directoryIdPath); // Clean up sidecar after successful delete/recycle @@ -524,7 +540,7 @@ public override unsafe int SetXAttr(ReadOnlySpan path, ReadOnlySpan fixed (byte* namePtr = NullTerminate(name)) fixed (void* valuePtr = value) - fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte* ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (setxattr(ciphertextPathPtr, namePtr, valuePtr, (nuint)value.Length, position, options) == -1) return -errno; @@ -540,7 +556,7 @@ public override unsafe int StatFS(ReadOnlySpan path, ref StatVfs statfs) return -ENOENT; fixed (StatVfs* statfsPtr = &statfs) - fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte* ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (statvfs(ciphertextPathPtr, statfsPtr) == -1) return -errno; @@ -652,7 +668,7 @@ public override unsafe int UpdateTimestamps(ReadOnlySpan path, ref TimeSpe return -ENOENT; var times = stackalloc TimeSpec[2] { atime, mtime }; - fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + fixed (byte* ciphertextPathPtr = ToNativePath(ciphertextPath)) { if (utimensat(AT_FDCWD, ciphertextPathPtr, times, 0) == -1) return -errno; @@ -683,6 +699,28 @@ public override int Write(ReadOnlySpan path, ulong offset, ReadOnlySpan + /// Gets the length of a file as seen by its writable open handles, which includes data not yet on disk. + /// + /// The largest length among the writable handles open on the file, or null when there are none. + private long? GetOpenLength(string ciphertextPath) + { + long? length = null; + foreach (var handle in handlesManager.OpenHandles) + { + if (handle is not MacFuseFileHandle macFuseFileHandle || !macFuseFileHandle.FileAccess.HasFlag(FileAccess.Write)) + continue; + + if (!macFuseFileHandle.CiphertextPath.Equals(ciphertextPath, StringComparison.Ordinal)) + continue; + + lock (macFuseFileHandle.Stream) + length = Math.Max(length ?? 0L, macFuseFileHandle.Stream.Length); + } + + return length; + } + protected override unsafe string? GetCiphertextPath(ReadOnlySpan plaintextName) { fixed (byte* plaintextNamePtr = plaintextName) diff --git a/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseFileHandle.cs b/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseFileHandle.cs index c26d6fae9..64b0ca060 100644 --- a/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseFileHandle.cs +++ b/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseFileHandle.cs @@ -1,4 +1,4 @@ -using SecureFolderFS.Core.FileSystem.OpenHandles; +using SecureFolderFS.Core.FileSystem.OpenHandles; namespace SecureFolderFS.Core.MacFuse.OpenHandles { @@ -9,14 +9,19 @@ internal sealed class MacFuseFileHandle : FileHandle public FileMode FileMode { get; } - public string Directory { get; } + public string Directory => Path.GetDirectoryName(CiphertextPath)!; - public MacFuseFileHandle(Stream stream, FileAccess fileAccess, FileMode fileMode, string directory) + /// + /// Gets or sets the path of the ciphertext file this handle is open on. Kept current across renames. + /// + public string CiphertextPath { get; set; } + + public MacFuseFileHandle(Stream stream, FileAccess fileAccess, FileMode fileMode, string ciphertextPath) : base(stream) { FileAccess = fileAccess; FileMode = fileMode; - Directory = directory; + CiphertextPath = ciphertextPath; } } } diff --git a/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.cs b/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.cs index 63750fd2b..4391599fc 100644 --- a/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.cs +++ b/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.cs @@ -1,4 +1,4 @@ -using SecureFolderFS.Core.FileSystem.Extensions; +using SecureFolderFS.Core.FileSystem.Extensions; using SecureFolderFS.Core.FileSystem.OpenHandles; using SecureFolderFS.Core.FileSystem.Streams; using SecureFolderFS.Storage.VirtualFileSystem; @@ -27,7 +27,7 @@ public IEnumerable OpenHandles { // Return a snapshot - the live collection could be mutated // by another thread while the caller is enumerating it - lock (handles) + lock (handlesLock) return handles.Values.ToArray(); } } @@ -67,10 +67,10 @@ public override ulong OpenFileHandle(string ciphertextPath, FileMode mode, FileA plaintextStream.Flush(); // Create handle - var fileHandle = new MacFuseFileHandle(plaintextStream, access, mode, Path.GetDirectoryName(ciphertextPath)!); + var fileHandle = new MacFuseFileHandle(plaintextStream, access, mode, ciphertextPath); var handle = handlesGenerator.ThreadSafeIncrement(); - lock (handles) + lock (handlesLock) handles.TryAdd(handle, fileHandle); return handle; @@ -87,14 +87,14 @@ public override ulong OpenDirectoryHandle(string ciphertextPath) public override THandle? GetHandle(ulong handleId) where THandle : class { - lock (handles) + lock (handlesLock) return base.GetHandle(handleId); } /// public override void CloseHandle(ulong handle) { - lock (handles) + lock (handlesLock) base.CloseHandle(handle); } } diff --git a/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV3_V4.cs b/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV3_V4.cs new file mode 100644 index 000000000..8c55790f7 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Migration/AppModels/MigratorV3_V4.cs @@ -0,0 +1,229 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; +using SecureFolderFS.Core.Cryptography; +using SecureFolderFS.Core.DataModels; +using SecureFolderFS.Core.Migration.DataModels; +using SecureFolderFS.Core.Migration.Helpers; +using SecureFolderFS.Core.VaultAccess; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Shared.SecureStore; +using SecureFolderFS.Storage.Extensions; + +namespace SecureFolderFS.Core.Migration.AppModels +{ + /// + internal sealed class MigratorV3_V4 : IVaultMigratorModel + { + private readonly IAsyncSerializer _streamSerializer; + private V3VaultConfigurationDataModel? _v3ConfigDataModel; // A verified data model + + /// + public IFolder VaultFolder { get; } + + public MigratorV3_V4(IFolder vaultFolder, IAsyncSerializer streamSerializer) + { + VaultFolder = vaultFolder; + _streamSerializer = streamSerializer; + } + + /// + public async Task UnlockAsync(IKeyBytes credentials, CancellationToken cancellationToken = default) + { + var configDataModel = await ReadConfigurationAsync(cancellationToken); + var keystoreDataModel = await ReadKeystoreAsync(cancellationToken); + + byte[] dekKey; + byte[] macKey; + var passkey = credentials.UseKey(static key => key.ToArray()); + try + { + (dekKey, macKey) = MigrationVaultParser.V3DeriveKeystore(passkey, keystoreDataModel); + } + finally + { + CryptographicOperations.ZeroMemory(passkey); + } + + using var dek = SecureKey.TakeOwnership(dekKey); + using var mac = SecureKey.TakeOwnership(macKey); + + // The migration re-signs the configuration with the vault's real MAC key. Verifying the existing + // signature beforehand makes sure a tampered V3 configuration cannot be laundered into a valid one + VerifyConfiguration(configDataModel, mac); + + // Retain the configuration for later use, only on success + _v3ConfigDataModel = configDataModel; + + // Create copies of keys for later use + return KeyPair.ImportKeys(dek, mac); + } + + /// + public async Task RecoverAsync(string encodedRecoveryKey, CancellationToken cancellationToken = default) + { + using var recoveryKey = KeyPair.CombineRecoveryKey(encodedRecoveryKey); + using var keyPair = KeyPair.CopyFromRecoveryKey(recoveryKey); + + // The keystore is carried over unchanged, so unlike the V2 to V3 migration, recovering here does not require new credentials to be configured + var configDataModel = await ReadConfigurationAsync(cancellationToken); + VerifyConfiguration(configDataModel, keyPair.MacKey); + + // Retain the configuration for later use, only on success + _v3ConfigDataModel = configDataModel; + + // Create copies of keys and dispose of the original instance + return keyPair.CreateCopy(); + } + + /// + public async Task MigrateAsync(IDisposable unlockContract, ProgressModel progress, CancellationToken cancellationToken = default) + { + _ = _v3ConfigDataModel ?? throw new InvalidOperationException($"{nameof(_v3ConfigDataModel)} is null."); + + if (unlockContract is not KeyPair keyPair) + throw new ArgumentException($"{nameof(unlockContract)} is not of the correct type."); + + // Begin progress report + progress.PercentageProgress?.Report(0d); + + // File Names. + // + // Names are converted before the configuration is bumped to V4. An interrupted run therefore + // leaves behind a vault that still declares V3 and can be migrated again, rather than one that + // declares V4 while part of its content is still encoded the old way. The conversion itself is + // idempotent, so repeating it only picks up where it left off + await ConvertFileNamesAsync(progress, cancellationToken); + + // Vault Configuration. + // + var v4ConfigDataModel = new VaultConfigurationDataModel() + { + ContentCipherId = _v3ConfigDataModel.ContentCipherId, + FileNameCipherId = _v3ConfigDataModel.FileNameCipherId, + FileNameEncodingId = _v3ConfigDataModel.FileNameEncodingId, + + // V3 predates file name shortening, so no name in the vault is stored in shortened form. + // A threshold of zero keeps shortening disabled, matching the existing ciphertext layout + ShorteningThreshold = 0, + RecycleBinSize = _v3ConfigDataModel.RecycleBinSize, + AuthenticationMethod = _v3ConfigDataModel.AuthenticationMethod, + Uid = _v3ConfigDataModel.Uid, + + // Both App Platform vaults and credential complementation postdate V3 + AppPlatform = null, + ComplementGeneration = 0, + Version = Constants.Vault.Versions.V4 + }; + + // Re-sign the payload, since V4 covers the shortening threshold that V3 did not have + var payloadMac = new byte[HMACSHA256.HashSizeInBytes]; + keyPair.MacKey.UseKey(macKey => VaultParser.CalculateConfigMac(v4ConfigDataModel, macKey, payloadMac)); + v4ConfigDataModel.PayloadMac = payloadMac; + + var configFile = await VaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken); + await using var configStream = await configFile.OpenReadWriteAsync(cancellationToken); + + // Create backup. The keystore is not modified by this migration and thus needs no backup + if (VaultFolder is IModifiableFolder modifiableFolder) + { + await BackupHelpers.CreateBackup( + modifiableFolder, + Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, + Constants.Vault.Versions.V3, + configStream, + cancellationToken); + } + + // Serialize before truncating so a failure here cannot leave behind an empty configuration + await using var serializedConfigStream = await _streamSerializer.SerializeAsync(v4ConfigDataModel, cancellationToken); + + // Reset length + configStream.SetLength(0L); + + // Copy serialized output + await serializedConfigStream.CopyToAsync(configStream, cancellationToken); + + // End progress report + progress.PercentageProgress?.Report(100d); + } + + /// + /// Re-encodes the vault's ciphertext names when they were written with the Base4K implementation used before V4. + /// + /// + /// The two Base4K implementations cannot read one another's output, so a Base4K vault whose names were + /// left alone would mount with every item unreadable. Names encoded as Base64Url, and vaults that do not + /// encrypt names at all, are unaffected and skipped. + /// + private async Task ConvertFileNamesAsync(ProgressModel progress, CancellationToken cancellationToken) + { + _ = _v3ConfigDataModel ?? throw new InvalidOperationException($"{nameof(_v3ConfigDataModel)} is null."); + + // Without name encryption, names are stored in plaintext and carry no encoding + if (string.IsNullOrEmpty(_v3ConfigDataModel.FileNameCipherId)) + return; + + if (!string.Equals(_v3ConfigDataModel.FileNameEncodingId, Cryptography.Constants.CipherId.ENCODING_BASE4K, StringComparison.Ordinal)) + return; + + var contentFolder = await VaultFolder.TryGetFolderByNameAsync(Constants.Vault.Names.VAULT_CONTENT_FOLDERNAME, cancellationToken); + if (contentFolder is null) + return; + + await Base4KNameMigrator.ConvertAsync(contentFolder, progress.PercentageProgress, cancellationToken); + } + + private async Task ReadConfigurationAsync(CancellationToken cancellationToken) + { + var configFile = await VaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken); + await using var configStream = await configFile.OpenReadAsync(cancellationToken); + + var configDataModel = await _streamSerializer.TryDeserializeAsync(configStream, cancellationToken); + if (configDataModel is null) + throw new FormatException($"{nameof(V3VaultConfigurationDataModel)} was not in the correct format."); + + if (configDataModel.Version != Constants.Vault.Versions.V3) + throw new FormatException($"Expected a vault of version {Constants.Vault.Versions.V3} but got {configDataModel.Version}."); + + return configDataModel; + } + + private async Task ReadKeystoreAsync(CancellationToken cancellationToken) + { + var keystoreFile = await VaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_KEYSTORE_FILENAME, cancellationToken); + await using var keystoreStream = await keystoreFile.OpenReadAsync(cancellationToken); + + var keystoreDataModel = await _streamSerializer.TryDeserializeAsync(keystoreStream, cancellationToken); + if (keystoreDataModel is null) + throw new FormatException($"{nameof(V3VaultKeystoreDataModel)} was not in the correct format."); + + return keystoreDataModel; + } + + private static void VerifyConfiguration(V3VaultConfigurationDataModel configDataModel, IKeyUsage macKey) + { + var isEqual = macKey.UseKey(key => + { + Span payloadMac = stackalloc byte[HMACSHA256.HashSizeInBytes]; + MigrationVaultParser.V3CalculateConfigMac(configDataModel, key, payloadMac); + + // Check if stored hash equals to computed hash + return CryptographicOperations.FixedTimeEquals(payloadMac, configDataModel.PayloadMac ?? []); + }); + + if (!isEqual) + throw new CryptographicException("Vault hash doesn't match the computed hash."); + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/Core/SecureFolderFS.Core.Migration/Helpers/Base4KNameMigrator.cs b/src/Core/SecureFolderFS.Core.Migration/Helpers/Base4KNameMigrator.cs new file mode 100644 index 000000000..546eb488c --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Migration/Helpers/Base4KNameMigrator.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Lex4K; +using OwlCore.Storage; +using SecureFolderFS.Core.Cryptography.Cipher; +using SecureFolderFS.Storage.Extensions; +using FileSystemNames = SecureFolderFS.Core.FileSystem.Constants.Names; + +namespace SecureFolderFS.Core.Migration.Helpers +{ + /// + /// Re-encodes Base4K ciphertext names from the legacy Lex4K alphabet to the Secomba implementation adopted after V3. + /// + internal static class Base4KNameMigrator + { + /// + /// Converts every legacy Base4K name found under . + /// + /// The vault's content folder. + /// An optional destination for percentage progress. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the number of converted names. + public static async Task ConvertAsync(IFolder contentFolder, IProgress? progress, CancellationToken cancellationToken = default) + { + if (contentFolder is not IModifiableFolder) + throw new UnauthorizedAccessException("The content folder is not modifiable, so file names cannot be migrated."); + + // Counting up front is what makes the percentage meaningful; renames take longer most of the time + var state = new ConversionState(await CountItemsAsync(contentFolder, cancellationToken), progress); + await ConvertFolderAsync(contentFolder, state, cancellationToken); + + progress?.Report(100d); + return state.Converted; + } + + private static async Task ConvertFolderAsync(IFolder folder, ConversionState state, CancellationToken cancellationToken) + { + // The listing is materialized because the items in it are renamed while it is walked + var items = new List(); + await foreach (var item in folder.GetItemsAsync(StorableType.All, cancellationToken)) + items.Add(item); + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Descend before renaming and start from deepest children first + if (item is IFolder childFolder) + await ConvertFolderAsync(childFolder, state, cancellationToken); + + await ConvertNameAsync(folder, item, state, cancellationToken); + state.Advance(); + } + } + + private static async Task ConvertNameAsync(IFolder parentFolder, IStorableChild item, ConversionState state, CancellationToken cancellationToken) + { + var convertedName = TryConvertName(item.Name); + if (convertedName is null) + return; + + // Reached only for a name that genuinely needs rewriting, so failing here is the honest + // outcome (completing the migration would otherwise leave the vault unreadable) + if (parentFolder is not IModifiableFolder modifiableFolder) + throw new UnauthorizedAccessException($"The folder '{parentFolder.Name}' is not modifiable, so file names cannot be migrated."); + + await modifiableFolder.RenameStorableAsync(item, convertedName, cancellationToken); + state.Converted++; + } + + /// + /// Converts a stored item name. + /// + /// The name as it appears on disk. + /// The re-encoded name, or if is not a legacy Base4K ciphertext name. + private static string? TryConvertName(string name) + { + // Everything the vault stores under a fixed or generated name carries no encoding to convert + if (!name.EndsWith(FileSystemNames.ENCRYPTED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + return null; + + var encoded = name[..^FileSystemNames.ENCRYPTED_FILE_EXTENSION.Length]; + if (encoded.Length == 0) + return null; + + // Anything the current decoder accepts is already in the target encoding. + // This exists solely because the user might cancel the migration operation, leaving some items already re-encoded + if (SecombaBase4K.Decode(encoded) is not null) + return null; + + byte[] raw; + try + { + raw = Base4K.DecodeChainToNewBuffer(encoded).ToArray(); + } + catch (Exception) + { + return null; + } + + // A rename cannot be taken back, so the decode is only trusted when re-encoding it reproduces the stored name exactly + if (raw.Length <= 1 || !string.Equals(Base4K.EncodeChainToString(raw), encoded, StringComparison.Ordinal)) + return null; + + return SecombaBase4K.Encode(raw) + FileSystemNames.ENCRYPTED_FILE_EXTENSION; + } + + private static async Task CountItemsAsync(IFolder folder, CancellationToken cancellationToken) + { + var count = 0; + await foreach (var item in folder.GetItemsAsync(StorableType.All, cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + + count++; + if (item is IFolder childFolder) + count += await CountItemsAsync(childFolder, cancellationToken); + } + + return count; + } + + private sealed class ConversionState(int totalItems, IProgress? progress) + { + private readonly int _totalItems = Math.Max(1, totalItems); + private int _processedItems; + + /// + /// Gets the number of names rewritten so far. + /// + public int Converted { get; set; } + + public void Advance() + { + _processedItems++; + progress?.Report(Math.Min(100d, _processedItems * 100d / _totalItems)); + } + } + } +} diff --git a/src/Core/SecureFolderFS.Core.Migration/Migrators.cs b/src/Core/SecureFolderFS.Core.Migration/Migrators.cs index 421fcff0f..21ab50b85 100644 --- a/src/Core/SecureFolderFS.Core.Migration/Migrators.cs +++ b/src/Core/SecureFolderFS.Core.Migration/Migrators.cs @@ -17,5 +17,10 @@ public static IVaultMigratorModel GetMigratorV2_V3(IFolder vaultFolder, IAsyncSe { return new MigratorV2_V3(vaultFolder, streamSerializer); } + + public static IVaultMigratorModel GetMigratorV3_V4(IFolder vaultFolder, IAsyncSerializer streamSerializer) + { + return new MigratorV3_V4(vaultFolder, streamSerializer); + } } } diff --git a/src/Core/SecureFolderFS.Core.Migration/SecureFolderFS.Core.Migration.csproj b/src/Core/SecureFolderFS.Core.Migration/SecureFolderFS.Core.Migration.csproj index f06d234a5..dd528aa51 100644 --- a/src/Core/SecureFolderFS.Core.Migration/SecureFolderFS.Core.Migration.csproj +++ b/src/Core/SecureFolderFS.Core.Migration/SecureFolderFS.Core.Migration.csproj @@ -7,8 +7,14 @@ true + + + + + + diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs index 49ed950a2..abbaead62 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs @@ -136,28 +136,81 @@ private static string BuildDocumentId(SafRoot safRoot, IStorable storable) /// private SafRoot? GetSafRootForDocumentId(string documentId) { - var split = documentId.Split(':', 2); - if (split.Length < 2) + if (!TryParseDocumentId(documentId, out var rootId, out _)) return null; - return _rootCollection?.GetSafRootForRootId(split[0]); + return _rootCollection?.GetSafRootForRootId(rootId); } - private IStorable? GetStorableForDocumentId(string documentId) + /// + /// Determines whether is a single, safe path component. + /// + /// The display name to check. + /// + /// A display name comes from the calling app and is joined onto a folder path. A name carrying a + /// directory separator or a parent-directory segment would place the item outside the folder the app was granted. + /// + private static bool IsValidDisplayName(string? displayName) { - if (_rootCollection is null) - return null; + return !string.IsNullOrWhiteSpace(displayName) + && displayName is not ("." or "..") + && displayName.IndexOf('/') < 0 + && displayName.IndexOf('\\') < 0 + && !IOPath.IsPathRooted(displayName); + } + + /// + /// Splits into its root ID and a canonical path. + /// + /// The document ID to parse. + /// The root ID of the document. + /// The canonical path of the document. + /// + /// Every document ID that reaches this provider is attacker-controlled. An app holding a tree + /// grant over one sub-folder can call with any + /// ID it likes. A parent-directory segment would resolve back out of the granted sub-tree and + /// hand it the whole vault, so such IDs are rejected outright rather than normalized away, and + /// the canonical form produced here is what both ancestry checks and resolution operate on. + /// + /// true if the document ID is well-formed and free of traversal; otherwise false. + private static bool TryParseDocumentId(string documentId, out string rootId, out string path) + { + rootId = string.Empty; + path = string.Empty; // Split the documentId into two: // 1. RootID - The source root of the document provider where the item belongs // 2. Path - The path to an item var split = documentId.Split(':', 2); if (split.Length < 2) + return false; + + rootId = split[0]; + var rawPath = split[1]; + var isRooted = rawPath.StartsWith('/'); + var segments = rawPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + + foreach (var segment in segments) + { + // Reject relative segments '..' escapes the granted sub-tree and '.' is + // an alias that would let the same item carry more than one document ID + if (segment is "." or "..") + return false; + } + + path = (isRooted ? "/" : string.Empty) + string.Join('/', segments); + + return true; + } + + private IStorable? GetStorableForDocumentId(string documentId) + { + if (_rootCollection is null) return null; - // Extract RootID and Path - var rootId = split[0]; - var path = split[1]; + // Extract RootID and Path, rejecting any traversal in the process + if (!TryParseDocumentId(documentId, out var rootId, out var path)) + return null; // Get root var safRoot = _rootCollection.GetSafRootForRootId(rootId); diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs index 41369fded..ec4cd41ce 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs @@ -66,17 +66,16 @@ public override bool IsChildDocument(string? parentDocumentId, string? documentI if (parentDocumentId is null || documentId is null) return false; - var parentSplit = parentDocumentId.Split(':', 2); - var childSplit = documentId.Split(':', 2); - if (parentSplit.Length < 2 || childSplit.Length < 2) + // Operate on the canonical form + if (!TryParseDocumentId(parentDocumentId, out var parentRootId, out var parentPath) || + !TryParseDocumentId(documentId, out var childRootId, out var childPath)) return false; // Both documents must belong to the same root - if (parentSplit[0] != childSplit[0]) + if (parentRootId != childRootId) return false; - var parentPath = parentSplit[1].TrimEnd('/'); - var childPath = childSplit[1]; + parentPath = parentPath.TrimEnd('/'); // The root folder is an ancestor of every document within it if (parentPath.Length == 0) @@ -93,6 +92,10 @@ public override bool IsChildDocument(string? parentDocumentId, string? documentI if (parentDocumentId is null || displayName is null) return null; + // The name is joined onto the parent's path, so it must not be able to escape it + if (!IsValidDisplayName(displayName)) + return null; + var parentStorable = GetStorableForDocumentId(parentDocumentId); if (parentStorable is not IModifiableFolder parentFolder) return null; @@ -322,7 +325,8 @@ public override void DeleteDocument(string? documentId) /// public override string? RenameDocument(string? documentId, string? displayName) { - if (string.IsNullOrWhiteSpace(displayName)) + // The new name is resolved against the item's parent, so it must not be able to escape it + if (!IsValidDisplayName(displayName)) return null; documentId = documentId == "null" ? null : documentId; diff --git a/src/Core/SecureFolderFS.Core.WebDav/Helpers/DriveMappingHelpers.cs b/src/Core/SecureFolderFS.Core.WebDav/Helpers/DriveMappingHelpers.cs index 70fe374c6..8480b6eed 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/Helpers/DriveMappingHelpers.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/Helpers/DriveMappingHelpers.cs @@ -17,7 +17,21 @@ public static void DisconnectNetworkDrive(string mountPath, bool force) } else if (OperatingSystem.IsMacCatalyst() || OperatingSystem.IsMacOS()) { - Process.Start("sh", $"-c \"diskutil unmount force \"{mountPath}\"\""); + // Invoke diskutil directly with an argument list so the mount path is passed as a single + // argv element and never handed to a shell. Building a "sh -c \"...\"" command string here + // let shell metacharacters in the mount path (which derives from the attacker-influenceable + // vault name) be parsed and executed as commands. + var startInfo = new ProcessStartInfo + { + FileName = "/usr/sbin/diskutil", + UseShellExecute = false + }; + startInfo.ArgumentList.Add("unmount"); + if (force) + startInfo.ArgumentList.Add("force"); + startInfo.ArgumentList.Add(mountPath); + + _ = Process.Start(startInfo); } } } diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs index cd982fc1d..1a0422655 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs @@ -77,6 +77,9 @@ public virtual async Task MountAsync(IFolder folder, IDisposable unloc /// A started bound to the resolved port. private static HttpListener StartListener(WebDavOptions options) { + if (!IsLoopbackDomain(options.Domain)) + throw new ArgumentOutOfRangeException(nameof(options), $"The WebDAV listener refuses to bind the non-loopback domain '{options.Domain}' while unauthenticated."); + HttpListenerException? lastException = null; for (var attempt = 0; attempt < MAX_LISTENER_START_ATTEMPTS; attempt++) { @@ -101,6 +104,25 @@ private static HttpListener StartListener(WebDavOptions options) throw lastException ?? new HttpListenerException(); } + /// + /// Determines whether resolves only to the local host. + /// + /// + /// The HttpListener wildcards '+' and '*' bind every interface and are rejected outright, as is + /// any name that does not parse to a loopback address. Hostnames other than "localhost" are not + /// resolved through DNS - a name whose resolution can change is not a binding this can vouch for. + /// + private static bool IsLoopbackDomain(string? domain) + { + if (string.IsNullOrWhiteSpace(domain)) + return false; + + if (domain is "localhost") + return true; + + return IPAddress.TryParse(domain.Trim('[', ']'), out var address) && IPAddress.IsLoopback(address); + } + /// public abstract Task GetVolumeNameAsync(string candidateName, CancellationToken cancellationToken = default); diff --git a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs index 96d929df4..f7f9e9a7d 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs @@ -19,6 +19,8 @@ using SecureFolderFS.Core.WinFsp.UnsafeNative; using FileInfo = Fsp.Interop.FileInfo; +// ReSharper disable InconsistentNaming + #pragma warning disable CA1416 // Validate platform compatibility namespace SecureFolderFS.Core.WinFsp.Callbacks @@ -699,10 +701,10 @@ public override int Create( return Trace(STATUS_ACCESS_DENIED, FileName); } - IDisposable? handle; var createdHandleId = FileSystem.Constants.INVALID_HANDLE; try { + IDisposable? handle; var ciphertextPath = GetCiphertextPathForUse(FileName); if ((CreateOptions & FILE_DIRECTORY_FILE) == 0) { diff --git a/src/Core/SecureFolderFS.Core.WinFsp/UnsafeNative/UnsafeNativeApis.cs b/src/Core/SecureFolderFS.Core.WinFsp/UnsafeNative/UnsafeNativeApis.cs index d2844d0ed..14d9be209 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/UnsafeNative/UnsafeNativeApis.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/UnsafeNative/UnsafeNativeApis.cs @@ -4,7 +4,7 @@ namespace SecureFolderFS.Core.WinFsp.UnsafeNative { internal static class UnsafeNativeApis { - [DllImport("Shlwapi.dll", CallingConvention = CallingConvention.StdCall, SetLastError = true)] + [DllImport("Shlwapi.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool PathMatchSpec( [In] string pszFile, diff --git a/src/Core/SecureFolderFS.Core/Constants.cs b/src/Core/SecureFolderFS.Core/Constants.cs index c307b33fc..2d107a0ae 100644 --- a/src/Core/SecureFolderFS.Core/Constants.cs +++ b/src/Core/SecureFolderFS.Core/Constants.cs @@ -50,6 +50,7 @@ public static class Associations public const string ASSOC_AUTHENTICATION = "authMode"; public const string ASSOC_VAULT_ID = "vaultId"; public const string ASSOC_APP_PLATFORM = "appPlatform"; + public const string ASSOC_COMPLEMENT_GENERATION = "complementGeneration"; public const string ASSOC_VERSION = "version"; } diff --git a/src/Core/SecureFolderFS.Core/DataModels/VaultAuthenticationDataModel.cs b/src/Core/SecureFolderFS.Core/DataModels/VaultAuthenticationDataModel.cs new file mode 100644 index 000000000..54df54aad --- /dev/null +++ b/src/Core/SecureFolderFS.Core/DataModels/VaultAuthenticationDataModel.cs @@ -0,0 +1,33 @@ +using System; +using System.ComponentModel; +using System.Text.Json.Serialization; +using static SecureFolderFS.Core.Constants.Vault; + +namespace SecureFolderFS.Core.DataModels +{ + /// + /// Represents the subset of the vault configuration that describes how a vault is unlocked. + /// + /// + /// These members are shared by every configuration format since V2. Reading only them allows the login + /// sequence to be assembled for outdated vaults awaiting migration, whose configuration cannot be + /// deserialized into because it lacks members introduced later. + /// + [Serializable] + public sealed record class VaultAuthenticationDataModel : VersionDataModel + { + /// + /// Gets the information about the authentication method used for this vault. + /// + [JsonPropertyName(Associations.ASSOC_AUTHENTICATION)] + [DefaultValue("")] + public string AuthenticationMethod { get; init; } = string.Empty; + + /// + /// Gets the unique identifier of the vault represented by a GUID. + /// + [JsonPropertyName(Associations.ASSOC_VAULT_ID)] + [DefaultValue("")] + public string Uid { get; init; } = string.Empty; + } +} diff --git a/src/Core/SecureFolderFS.Core/DataModels/VaultConfigurationDataModel.cs b/src/Core/SecureFolderFS.Core/DataModels/VaultConfigurationDataModel.cs index 384741df5..df07e6ae0 100644 --- a/src/Core/SecureFolderFS.Core/DataModels/VaultConfigurationDataModel.cs +++ b/src/Core/SecureFolderFS.Core/DataModels/VaultConfigurationDataModel.cs @@ -70,6 +70,19 @@ public sealed record class VaultConfigurationDataModel : VersionDataModel [JsonPropertyName(Associations.ASSOC_APP_PLATFORM)] public AppPlatformVaultOptions? AppPlatform { get; init; } + /// + /// Gets the rotation counter for complementation key material. + /// + /// + /// Mixed into the complement key derivation so that bumping it re-keys the keystore and + /// invalidates previously issued complementation shares. A value of zero (the default for + /// non-complemented or never-rotated vaults) reproduces the legacy derivation and is therefore + /// omitted from the payload MAC to preserve backwards compatibility. + /// + [JsonPropertyName(Associations.ASSOC_COMPLEMENT_GENERATION)] + [DefaultValue(0)] + public int ComplementGeneration { get; set; } + /// /// Gets the HMAC-SHA256 hash of the payload. /// @@ -89,6 +102,7 @@ public static VaultConfigurationDataModel V4FromVaultOptions(VaultOptions vaultO RecycleBinSize = vaultOptions.RecycleBinSize, Uid = vaultOptions.VaultId ?? Guid.NewGuid().ToString(), AppPlatform = vaultOptions.AppPlatform, + ComplementGeneration = vaultOptions.ComplementGeneration, PayloadMac = new byte[HMACSHA256.HashSizeInBytes] }; } diff --git a/src/Core/SecureFolderFS.Core/DataModels/VaultKeystoreDataModel.cs b/src/Core/SecureFolderFS.Core/DataModels/VaultKeystoreDataModel.cs index 7c2873b09..8992d7798 100644 --- a/src/Core/SecureFolderFS.Core/DataModels/VaultKeystoreDataModel.cs +++ b/src/Core/SecureFolderFS.Core/DataModels/VaultKeystoreDataModel.cs @@ -23,31 +23,5 @@ public sealed record class VaultKeystoreDataModel /// [JsonPropertyName("salt")] public byte[]? Salt { get; init; } - - /// - /// Gets the AES-256-GCM ciphertext of the 256-bit SoftwareEntropy value. - /// SoftwareEntropy is a CSPRNG secret mixed into Argon2id input via HKDF-Extract, - /// raising the quantum security floor of all authentication methods to 256 bits - /// regardless of auth factor entropy. - /// It is encrypted under a key derived from the passkey so all active auth - /// factors are required to recover it. - /// - /// The value is generated at vault creation and can also be rotated during - /// credential changes when rebuilding the V4 keystore. - /// - [JsonPropertyName("c_softwareEntropy")] - public byte[]? EncryptedSoftwareEntropy { get; init; } - - /// - /// Gets the nonce used when encrypting . - /// - [JsonPropertyName("entropyNonce")] - public byte[]? SoftwareEntropyNonce { get; init; } - - /// - /// Gets the AES-256-GCM authentication tag for . - /// - [JsonPropertyName("entropyTag")] - public byte[]? SoftwareEntropyTag { get; init; } } } \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core/Models/SecurityWrapper.cs b/src/Core/SecureFolderFS.Core/Models/SecurityWrapper.cs index 6300d8813..e098d66a1 100644 --- a/src/Core/SecureFolderFS.Core/Models/SecurityWrapper.cs +++ b/src/Core/SecureFolderFS.Core/Models/SecurityWrapper.cs @@ -8,7 +8,7 @@ namespace SecureFolderFS.Core.Models { - internal sealed class SecurityWrapper : IWrapper, IEnumerable>, IDisposable + internal sealed class SecurityWrapper : IWrapper, IWrapper, IWrapper, IEnumerable>, IDisposable { private readonly KeyPair _keyPair; private readonly VaultConfigurationDataModel _configDataModel; @@ -21,6 +21,19 @@ internal sealed class SecurityWrapper : IWrapper, IEnumerable + KeyPair IWrapper.Inner => _keyPair; + + /// + /// Gets the vault configuration whose MAC was verified during unlock. + /// + /// + /// Routines that rewrite the configuration must derive it from this model rather than from a + /// fresh unvalidated read of the vault directory, otherwise a configuration an attacker edited + /// on disk would be re-signed with the vault's genuine MAC key. + /// + VaultConfigurationDataModel IWrapper.Inner => _configDataModel; + public SecurityWrapper(KeyPair keyPair, VaultConfigurationDataModel configDataModel) { _keyPair = keyPair; diff --git a/src/Core/SecureFolderFS.Core/Routines/IModifyComplementationRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/IModifyComplementationRoutine.cs new file mode 100644 index 000000000..9f61dfac1 --- /dev/null +++ b/src/Core/SecureFolderFS.Core/Routines/IModifyComplementationRoutine.cs @@ -0,0 +1,9 @@ +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Core.Routines +{ + public interface IModifyComplementationRoutine : IContractRoutine, IOptionsRoutine + { + void SetCredentials(ComplementationCredentials credentials); + } +} diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/AppPlatformCreationRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/AppPlatformCreationRoutine.cs new file mode 100644 index 000000000..1d2e9409d --- /dev/null +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/AppPlatformCreationRoutine.cs @@ -0,0 +1,91 @@ +using System; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; +using SecureFolderFS.Core.Cryptography; +using SecureFolderFS.Core.DataModels; +using SecureFolderFS.Core.Models; +using SecureFolderFS.Core.VaultAccess; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Shared.SecureStore; +using static SecureFolderFS.Core.Constants.Vault; +using static SecureFolderFS.Core.Cryptography.Constants; + +namespace SecureFolderFS.Core.Routines.Operational +{ + /// + /// Creation routine for App Platform vaults. Generates DEK+MAC internally (no password, no keystore.cfg). + /// + public sealed class AppPlatformCreationRoutine : ICreationRoutine + { + private readonly IFolder _vaultFolder; + private readonly VaultWriter _vaultWriter; + private VaultConfigurationDataModel? _configDataModel; + private SecureKey? _dekKey; + private SecureKey? _macKey; + + public AppPlatformCreationRoutine(IFolder vaultFolder, VaultWriter vaultWriter) + { + _vaultFolder = vaultFolder; + _vaultWriter = vaultWriter; + } + + /// + public Task InitAsync(CancellationToken cancellationToken = default) + { + var dekKey = new byte[KeyTraits.DEK_KEY_LENGTH]; + var macKey = new byte[KeyTraits.MAC_KEY_LENGTH]; + + RandomNumberGenerator.Fill(dekKey); + RandomNumberGenerator.Fill(macKey); + + _dekKey = SecureKey.TakeOwnership(dekKey); + _macKey = SecureKey.TakeOwnership(macKey); + + return Task.CompletedTask; + } + + /// + public void SetCredentials(IKeyUsage passkey) + { + // No-op: App Platform vaults don't use passkey-derived keys + } + + /// + public void SetOptions(VaultOptions vaultOptions) + { + _configDataModel = VaultConfigurationDataModel.V4FromVaultOptions(vaultOptions); + } + + /// + public async Task FinalizeAsync(CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(_configDataModel); + ArgumentNullException.ThrowIfNull(_dekKey); + ArgumentNullException.ThrowIfNull(_macKey); + + _macKey.UseKey(macKey => + { + VaultParser.CalculateConfigMac(_configDataModel, macKey, _configDataModel.PayloadMac); + }); + + // Write only sfconfig.cfg - no keystore.cfg for App Platform vaults + await _vaultWriter.WriteConfigurationAsync(_configDataModel, cancellationToken); + + // Create the content folder + if (_vaultFolder is IModifiableFolder modifiableFolder) + await modifiableFolder.CreateFolderAsync(Names.VAULT_CONTENT_FOLDERNAME, true, cancellationToken); + + return new SecurityWrapper(KeyPair.ImportKeys(_dekKey, _macKey), _configDataModel); + } + + /// + public void Dispose() + { + _dekKey?.Dispose(); + _macKey?.Dispose(); + } + } +} diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/AppPlatformUnlockRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/AppPlatformUnlockRoutine.cs new file mode 100644 index 000000000..602ed63ad --- /dev/null +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/AppPlatformUnlockRoutine.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Core.Cryptography; +using SecureFolderFS.Core.DataModels; +using SecureFolderFS.Core.Models; +using SecureFolderFS.Core.Validators; +using SecureFolderFS.Core.VaultAccess; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.SecureStore; + +namespace SecureFolderFS.Core.Routines.Operational +{ + /// + /// Unlock routine for App Platform vaults. Accepts DEK || MAC directly from the server-brokered key hierarchy. + /// + internal sealed class AppPlatformUnlockRoutine : ICredentialsRoutine + { + private readonly VaultReader _vaultReader; + private VaultConfigurationDataModel? _configDataModel; + private SecureKey? _dekKey; + private SecureKey? _macKey; + + public AppPlatformUnlockRoutine(VaultReader vaultReader) + { + _vaultReader = vaultReader; + } + + /// + public async Task InitAsync(CancellationToken cancellationToken) + { + _configDataModel = await _vaultReader.ReadConfigurationAsync(cancellationToken); + } + + /// + public void SetCredentials(IKeyUsage passkey) + { + ArgumentNullException.ThrowIfNull(_configDataModel); + + passkey.UseKey(key => + { + if (key.Length != Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH + Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH) + throw new ArgumentException($"Expected {Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH + Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH} bytes (DEK+MAC), got {key.Length}."); + + var dekBytes = new byte[Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH]; + var macBytes = new byte[Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH]; + + key.Slice(0, Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH).CopyTo(dekBytes); + key.Slice(Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH, Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH).CopyTo(macBytes); + + _dekKey = SecureKey.TakeOwnership(dekBytes); + _macKey = SecureKey.TakeOwnership(macBytes); + }); + } + + /// + public async Task FinalizeAsync(CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(_dekKey); + ArgumentNullException.ThrowIfNull(_macKey); + ArgumentNullException.ThrowIfNull(_configDataModel); + + using (_dekKey) + using (_macKey) + { + var validator = new ConfigurationValidator(_macKey); + await validator.ValidateAsync(_configDataModel, cancellationToken); + + return new SecurityWrapper(KeyPair.ImportKeys(_dekKey, _macKey), _configDataModel); + } + } + + /// + public void Dispose() + { + _dekKey?.Dispose(); + _macKey?.Dispose(); + } + } +} diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs index c339f2fe9..fcdeb6d64 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs @@ -1,19 +1,19 @@ using System; using System.Linq; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.DataModels; using SecureFolderFS.Core.Models; -using SecureFolderFS.Core.Routines; using SecureFolderFS.Core.VaultAccess; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; namespace SecureFolderFS.Core.Routines.Operational { - public sealed class ModifyComplementationRoutine : IFinalizationRoutine, IContractRoutine, IOptionsRoutine + public sealed class ModifyComplementationRoutine : IModifyComplementationRoutine { private const int ComplementSecretLength = 32; @@ -24,9 +24,13 @@ public sealed class ModifyComplementationRoutine : IFinalizationRoutine, IContra private VaultKeystoreDataModel? _keystoreDataModel; private VaultConfigurationDataModel? _existingConfigDataModel; private VaultConfigurationDataModel? _configDataModel; + private VaultConfigurationDataModel? _verifiedConfigDataModel; private VaultSharesDataModel? _existingSharesDataModel; private VaultSharesDataModel? _sharesDataModel; private bool _writeShares; + private bool _writeConfigBeforeKeystore; + + private int ExistingGeneration => _existingConfigDataModel?.ComplementGeneration ?? 0; public ModifyComplementationRoutine(VaultReader vaultReader, VaultWriter vaultWriter) { @@ -48,16 +52,52 @@ public void SetUnlockContract(IDisposable unlockContract) if (unlockContract is not IWrapper securityWrapper) throw new ArgumentException($"The {nameof(unlockContract)} is invalid."); - _keyPair = securityWrapper.Inner.KeyPair; + if (unlockContract is not IWrapper configurationWrapper) + throw new ArgumentException($"The {nameof(unlockContract)} does not carry a verified configuration."); + + // Operate on a private copy so this routine never disposes of the caller's unlock contract. + // This keeps the contract valid for retries if an attempt fails, and valid for the session after success. + _keyPair = securityWrapper.Inner.KeyPair.CreateCopy(); + + // Retain the configuration whose MAC was verified during unlock, so the rewrite below + // is derived from authenticated data rather than from a fresh read of the vault directory + _verifiedConfigDataModel = configurationWrapper.Inner; } /// public void SetOptions(VaultOptions vaultOptions) { - _configDataModel = VaultConfigurationDataModel.V4FromVaultOptions(vaultOptions); + ArgumentNullException.ThrowIfNull(_verifiedConfigDataModel); + + // Build on the model that was MAC-verified at unlock rather than on the caller's unvalidated + // re-read of sfconfig.cfg, so a configuration an attacker edited on disk can never be + // re-signed here with the vault's genuine MAC key (see ModifyCredentialsRoutine) + EnsureMatchesVerified(nameof(vaultOptions.ContentCipherId), _verifiedConfigDataModel.ContentCipherId, vaultOptions.ContentCipherId); + EnsureMatchesVerified(nameof(vaultOptions.FileNameCipherId), _verifiedConfigDataModel.FileNameCipherId, vaultOptions.FileNameCipherId); + EnsureMatchesVerified(nameof(vaultOptions.NameEncodingId), _verifiedConfigDataModel.FileNameEncodingId, vaultOptions.NameEncodingId); + EnsureMatchesVerified(nameof(vaultOptions.VaultId), _verifiedConfigDataModel.Uid, vaultOptions.VaultId); + + // Never invent a new vault ID while modifying. The complement key derivations are bound to it, + // so a regenerated id would silently lock every credential out of the vault + _configDataModel = _verifiedConfigDataModel with + { + AuthenticationMethod = vaultOptions.UnlockProcedure.ToString(), + ComplementGeneration = vaultOptions.ComplementGeneration, + RecycleBinSize = vaultOptions.RecycleBinSize, + PayloadMac = new byte[HMACSHA256.HashSizeInBytes] + }; + return; + + static void EnsureMatchesVerified(string field, string verified, string? supplied) + { + // A null value means the caller did not carry an opinion, so the verified one stands + if (supplied is not null && !string.Equals(verified, supplied, StringComparison.Ordinal)) + throw new CryptographicException($"The vault configuration on disk does not match the one authenticated at unlock ('{field}'). The vault directory may have been tampered with."); + } } - public void SetCredentials(ComplementationCredentials credentials, CancellationToken cancellationToken = default) + /// + public void SetCredentials(ComplementationCredentials credentials) { ArgumentNullException.ThrowIfNull(_keyPair); ArgumentNullException.ThrowIfNull(_existingConfigDataModel); @@ -65,7 +105,6 @@ public void SetCredentials(ComplementationCredentials credentials, CancellationT ArgumentNullException.ThrowIfNull(_configDataModel); ArgumentNullException.ThrowIfNull(credentials); - cancellationToken.ThrowIfCancellationRequested(); var oldAuthentication = AuthenticationMethod.FromString(_existingConfigDataModel.AuthenticationMethod); var newAuthentication = AuthenticationMethod.FromString(_configDataModel.AuthenticationMethod); var primaryChanged = !oldAuthentication.Methods.SequenceEqual(newAuthentication.Methods, StringComparer.Ordinal); @@ -101,40 +140,89 @@ public void SetCredentials(ComplementationCredentials credentials, CancellationT throw new InvalidOperationException("The requested authentication change does not involve complementation."); } + /// + public async Task FinalizeAsync(CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(_keyPair); + ArgumentNullException.ThrowIfNull(_keystoreDataModel); + ArgumentNullException.ThrowIfNull(_configDataModel); + + _keyPair.MacKey.UseKey(macKey => + { + VaultParser.CalculateConfigMac(_configDataModel, macKey, _configDataModel.PayloadMac); + }); + + // The keystore and configuration cannot be updated atomically together. Order the two writes + // per operation so that an interruption always lands in a state the unlock routine can recover. + // The config claims complementation while the keystore is still keyed under the raw primary. + // Shares are written last (added) or, for a removal, the file is deleted last - in both cases a + // crash before that step leaves a usable vault. + if (_writeConfigBeforeKeystore) + { + await _vaultWriter.WriteConfigurationAsync(_configDataModel, cancellationToken); + await _vaultWriter.WriteKeystoreAsync(_keystoreDataModel, cancellationToken); + } + else + { + await _vaultWriter.WriteKeystoreAsync(_keystoreDataModel, cancellationToken); + await _vaultWriter.WriteConfigurationAsync(_configDataModel, cancellationToken); + } + + if (_writeShares) + await _vaultWriter.WriteComplementationAsync(_sharesDataModel, cancellationToken); + + using (_keyPair) + return new SecurityWrapper(_keyPair.CreateCopy(), _configDataModel); + } + private void AddComplementation( ComplementationCredentials credentials, AuthenticationMethod oldAuthentication, AuthenticationMethod newAuthentication) { + ArgumentNullException.ThrowIfNull(_existingKeystoreDataModel); + ArgumentNullException.ThrowIfNull(_existingConfigDataModel); var newComplementMethod = newAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing."); - var currentKeystoreKey = ExportKey(RequireCredential(credentials.CurrentKeystoreCredential, "Current keystore credentials are required.")); - var currentPrimaryCredential = credentials.NewPrimaryCredential - ?? credentials.CurrentPrimaryCredential - ?? (oldAuthentication.Methods.Length == 1 ? credentials.CurrentKeystoreCredential : null); - var newComplementKey = ExportKey(RequireCredential(credentials.NewComplementCredential, "New complement credentials are required.")); + + // Always derive at a fresh generation. Reusing the existing counter would let a + // remove-then-re-add cycle land on a previously issued generation, resurrecting shares + // (and thus credentials) revoked under it. + var generation = ExistingGeneration + 1; + byte[]? currentKeystoreKey = null; byte[]? newPrimaryKey = null; - byte[]? softwareEntropy = null; + byte[]? newComplementKey = null; byte[]? complementSecret = null; try { + currentKeystoreKey = ExportKey(RequireCredential(credentials.CurrentKeystoreCredential, "Current keystore credentials are required.")); + var currentPrimaryCredential = credentials.NewPrimaryCredential + ?? credentials.CurrentPrimaryCredential + ?? (oldAuthentication.Methods.Length == 1 ? credentials.CurrentKeystoreCredential : null); newPrimaryKey = ExportKey(RequireCredential(currentPrimaryCredential, "Current primary credentials are required.")); - softwareEntropy = DecryptSoftwareEntropy(currentKeystoreKey); - complementSecret = DeriveComplementSecret(newPrimaryKey, GetPrimaryMethod(newAuthentication)); + newComplementKey = ExportKey(RequireCredential(credentials.NewComplementCredential, "New complement credentials are required.")); + - ReEncryptKeystore(complementSecret, softwareEntropy); - _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(complementSecret, newComplementKey, GetVaultId(), newComplementMethod)); + VaultParser.VerifyKeystoreKey(currentKeystoreKey, _existingKeystoreDataModel); + complementSecret = DeriveComplementSecret(newPrimaryKey, GetPrimaryMethod(newAuthentication), generation); + + ReEncryptKeystore(complementSecret); + _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(complementSecret, newComplementKey, _existingConfigDataModel.Uid, newComplementMethod, generation)); + _configDataModel!.ComplementGeneration = generation; _writeShares = true; + + // Write the (complemented) config before the re-keyed keystore. If interrupted in between, + // the on-disk state is "config says complemented, keystore still keyed under the raw primary", + // which the unlock routine recovers via its direct-derivation fallback. + _writeConfigBeforeKeystore = true; } finally { - Zero(newPrimaryKey, currentKeystoreKey); Zero(complementSecret); - Zero(softwareEntropy); Zero(newComplementKey); + Zero(newPrimaryKey); Zero(currentKeystoreKey); } - } private void ReplaceComplementation( @@ -142,56 +230,70 @@ private void ReplaceComplementation( AuthenticationMethod oldAuthentication, AuthenticationMethod newAuthentication) { + ArgumentNullException.ThrowIfNull(_existingKeystoreDataModel); + ArgumentNullException.ThrowIfNull(_existingConfigDataModel); + var newComplementMethod = newAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing."); + var oldGeneration = ExistingGeneration; + var newGeneration = oldGeneration + 1; byte[]? currentPrimaryKey = null; - byte[]? currentComplementKey = null; - var newComplementKey = ExportKey(RequireCredential(credentials.NewComplementCredential, "New complement credentials are required.")); - byte[]? complementSecret = null; - byte[]? softwareEntropy = null; - (byte[] ComplementSecret, byte[] SoftwareEntropy) recoveredData; + byte[]? newComplementKey = null; + byte[]? oldComplementSecret = null; + byte[]? newComplementSecret = null; try { - recoveredData = credentials.CurrentComplementCredential is not null - ? RecoverComplementSecretFromShare(currentComplementKey = ExportKey(credentials.CurrentComplementCredential), oldAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing.")) - : RecoverComplementSecretFromPrimary(currentPrimaryKey = ExportKey(RequireCredential(credentials.CurrentPrimaryCredential, "Current primary or complement credentials are required.")), oldAuthentication); - complementSecret = recoveredData.ComplementSecret; - softwareEntropy = recoveredData.SoftwareEntropy; - - ReEncryptKeystore(complementSecret, softwareEntropy); - _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(complementSecret, newComplementKey, GetVaultId(), newComplementMethod)); + // Rotating the complement secret requires the primary credential. The "change second factor" + // flow always supplies it because its login is constrained to the primary method. + currentPrimaryKey = ExportKey(RequireCredential(credentials.CurrentPrimaryCredential, "Current primary credentials are required to rotate complementation.")); + newComplementKey = ExportKey(RequireCredential(credentials.NewComplementCredential, "New complement credentials are required.")); + + // Confirm the current (old-generation) secret actually opens the keystore... + oldComplementSecret = DeriveComplementSecret(currentPrimaryKey, GetPrimaryMethod(oldAuthentication), oldGeneration); + VaultParser.VerifyKeystoreKey(oldComplementSecret, _existingKeystoreDataModel); + + // ...then re-key the keystore under a freshly rotated secret so the previous share can no longer unlock it. + newComplementSecret = DeriveComplementSecret(currentPrimaryKey, GetPrimaryMethod(newAuthentication), newGeneration); + + ReEncryptKeystore(newComplementSecret); + _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(newComplementSecret, newComplementKey, _existingConfigDataModel.Uid, newComplementMethod, newGeneration)); + _configDataModel!.ComplementGeneration = newGeneration; _writeShares = true; } finally { - Zero(softwareEntropy); - Zero(complementSecret); + Zero(newComplementSecret); + Zero(oldComplementSecret); Zero(newComplementKey); - Zero(currentComplementKey); Zero(currentPrimaryKey); } } private void RemoveComplementation(ComplementationCredentials credentials, AuthenticationMethod oldAuthentication) { - var currentPrimaryKey = ExportKey(RequireCredential(credentials.CurrentPrimaryCredential, "Current primary credentials are required.")); + ArgumentNullException.ThrowIfNull(_existingKeystoreDataModel); + + var generation = ExistingGeneration; + byte[]? currentPrimaryKey = null; byte[]? targetPasskey = null; byte[]? complementSecret = null; - byte[]? softwareEntropy = null; - try { + currentPrimaryKey = ExportKey(RequireCredential(credentials.CurrentPrimaryCredential, "Current primary credentials are required.")); targetPasskey = credentials.NewPrimaryCredential is null ? currentPrimaryKey : ExportKey(credentials.NewPrimaryCredential); - complementSecret = DeriveComplementSecret(currentPrimaryKey, GetPrimaryMethod(oldAuthentication)); - softwareEntropy = DecryptSoftwareEntropy(complementSecret); + complementSecret = DeriveComplementSecret(currentPrimaryKey, GetPrimaryMethod(oldAuthentication), generation); + VaultParser.VerifyKeystoreKey(complementSecret, _existingKeystoreDataModel); - ReEncryptKeystore(targetPasskey, softwareEntropy); + ReEncryptKeystore(targetPasskey); _sharesDataModel = null; _writeShares = true; + + // Preserve the counter through the non-complemented period. It is a monotonic + // high-water mark: resetting it would allow a later re-add to reuse an old generation. + _configDataModel!.ComplementGeneration = generation; } finally { - Zero(softwareEntropy); Zero(complementSecret); Zero(targetPasskey, currentPrimaryKey); Zero(currentPrimaryKey); @@ -203,34 +305,43 @@ private void ChangePrimaryAndPreserveComplementation( AuthenticationMethod oldAuthentication, AuthenticationMethod newAuthentication) { + ArgumentNullException.ThrowIfNull(_existingConfigDataModel); + var oldComplementMethod = oldAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing."); var newComplementMethod = newAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing."); - var currentComplementKey = ExportKey(RequireCredential(credentials.CurrentComplementCredential, "Current complement credentials are required.")); - var newPrimaryKey = ExportKey(RequireCredential(credentials.NewPrimaryCredential, "New primary credentials are required.")); + + // Changing the primary already rotates the complement secret (it is derived from the primary), + // but the generation is bumped anyway so that cycling the primary back to a previous credential + // can never reproduce a secret that older shares were issued for. + var oldGeneration = ExistingGeneration; + var newGeneration = oldGeneration + 1; + byte[]? currentComplementKey = null; + byte[]? newPrimaryKey = null; byte[]? newComplementKey = null; byte[]? oldComplementSecret = null; byte[]? newComplementSecret = null; - byte[]? softwareEntropy = null; - (byte[] ComplementSecret, byte[] SoftwareEntropy) recoveredData; try { - recoveredData = RecoverComplementSecretFromShare(currentComplementKey, oldComplementMethod); - oldComplementSecret = recoveredData.ComplementSecret; - softwareEntropy = recoveredData.SoftwareEntropy; - newComplementSecret = DeriveComplementSecret(newPrimaryKey, GetPrimaryMethod(newAuthentication)); + // Both exports live inside the try so that a failure exporting the second one still + // zeroes the first; hoisting them above it would strand that copy in memory. + currentComplementKey = ExportKey(RequireCredential(credentials.CurrentComplementCredential, "Current complement credentials are required.")); + newPrimaryKey = ExportKey(RequireCredential(credentials.NewPrimaryCredential, "New primary credentials are required.")); + + oldComplementSecret = RecoverComplementSecretFromShare(currentComplementKey, oldComplementMethod, oldGeneration); + newComplementSecret = DeriveComplementSecret(newPrimaryKey, GetPrimaryMethod(newAuthentication), newGeneration); newComplementKey = string.Equals(oldComplementMethod, newComplementMethod, StringComparison.Ordinal) ? currentComplementKey : ExportKey(credentials.NewComplementCredential ?? throw new InvalidOperationException("New complement credentials are required.")); - ReEncryptKeystore(newComplementSecret, softwareEntropy); - _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(newComplementSecret, newComplementKey, GetVaultId(), newComplementMethod)); + ReEncryptKeystore(newComplementSecret); + _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(newComplementSecret, newComplementKey, _existingConfigDataModel.Uid, newComplementMethod, newGeneration)); + _configDataModel!.ComplementGeneration = newGeneration; _writeShares = true; } finally { - Zero(softwareEntropy); Zero(newComplementSecret); Zero(oldComplementSecret); Zero(newComplementKey, currentComplementKey); @@ -239,57 +350,38 @@ private void ChangePrimaryAndPreserveComplementation( } } - private (byte[] ComplementSecret, byte[] SoftwareEntropy) RecoverComplementSecretFromPrimary(byte[] currentPrimaryKey, AuthenticationMethod oldAuthentication) + [SkipLocalsInit] + private byte[] RecoverComplementSecretFromShare(byte[] currentKey, string complementMethod, int generation) { - byte[]? complementSecret = null; - byte[]? softwareEntropy = null; - - try - { - complementSecret = DeriveComplementSecret(currentPrimaryKey, GetPrimaryMethod(oldAuthentication)); - softwareEntropy = DecryptSoftwareEntropy(complementSecret); - return (complementSecret, softwareEntropy); - } - catch - { - Zero(complementSecret); - Zero(softwareEntropy); - throw; - } - } + ArgumentNullException.ThrowIfNull(_existingKeystoreDataModel); + ArgumentNullException.ThrowIfNull(_existingConfigDataModel); - private (byte[] ComplementSecret, byte[] SoftwareEntropy) RecoverComplementSecretFromShare(byte[] currentKey, string complementMethod, CryptographicException? fallbackException = null) - { - var share = GetShare(complementMethod); + var share = _existingSharesDataModel?.Shares?.FirstOrDefault(x => string.Equals(x.AuthenticationMethodId, complementMethod, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Complementation share '{complementMethod}' was not found."); byte[]? complementSecret = null; - byte[]? softwareEntropy = null; - try { - complementSecret = VaultParser.UnwrapComplementSecret(currentKey, GetVaultId(), share); - softwareEntropy = DecryptSoftwareEntropy(complementSecret); - return (complementSecret, softwareEntropy); - } - catch (CryptographicException) when (fallbackException is not null) - { - Zero(complementSecret); - Zero(softwareEntropy); - throw fallbackException; + // UnwrapComplementSecret is authenticated (AES-GCM), so a wrong key throws here; + // the extra keystore verification confirms the recovered secret still opens the keystore. + complementSecret = VaultParser.UnwrapComplementSecret(currentKey, _existingConfigDataModel.Uid, share, generation); + VaultParser.VerifyKeystoreKey(complementSecret, _existingKeystoreDataModel); + return complementSecret; } catch { Zero(complementSecret); - Zero(softwareEntropy); throw; } } - private byte[] DeriveComplementSecret(byte[] passkey, string authenticationMethodId) + private byte[] DeriveComplementSecret(byte[] passkey, string authenticationMethodId, int generation) { + ArgumentNullException.ThrowIfNull(_existingConfigDataModel); + var complementSecret = new byte[ComplementSecretLength]; try { - VaultParser.DeriveComplementKey(passkey, GetVaultId(), authenticationMethodId, complementSecret); + VaultParser.DeriveComplementKey(passkey, _existingConfigDataModel.Uid, authenticationMethodId, generation, complementSecret); return complementSecret; } catch @@ -299,24 +391,7 @@ private byte[] DeriveComplementSecret(byte[] passkey, string authenticationMetho } } - private byte[] DecryptSoftwareEntropy(byte[] passkey) - { - ArgumentNullException.ThrowIfNull(_existingKeystoreDataModel); - - var softwareEntropy = new byte[ComplementSecretLength]; - try - { - VaultParser.DecryptSoftwareEntropy(passkey, _existingKeystoreDataModel, softwareEntropy); - return softwareEntropy; - } - catch - { - Zero(softwareEntropy); - throw; - } - } - - private void ReEncryptKeystore(byte[] passkey, byte[] softwareEntropy) + private void ReEncryptKeystore(byte[] passkey) { ArgumentNullException.ThrowIfNull(_keyPair); @@ -324,20 +399,7 @@ private void ReEncryptKeystore(byte[] passkey, byte[] softwareEntropy) RandomNumberGenerator.Fill(salt); _keystoreDataModel = _keyPair.UseKeys((dekKey, macKey) => - VaultParser.ReEncryptKeystore(passkey, dekKey, macKey, salt, softwareEntropy)); - } - - private VaultShareDataModel GetShare(string authenticationMethodId) - { - return _existingSharesDataModel?.Shares?.FirstOrDefault(x => - string.Equals(x.AuthenticationMethodId, authenticationMethodId, StringComparison.Ordinal)) - ?? throw new InvalidOperationException($"Complementation share '{authenticationMethodId}' was not found."); - } - - private string GetVaultId() - { - ArgumentNullException.ThrowIfNull(_existingConfigDataModel); - return _existingConfigDataModel.Uid; + VaultParser.EncryptKeystore(passkey, dekKey, macKey, salt)); } private static string GetPrimaryMethod(AuthenticationMethod authenticationMethod) @@ -379,34 +441,12 @@ private static void Zero(byte[]? key) CryptographicOperations.ZeroMemory(key); } - private static void Zero(byte[]? key, byte[] sameAs) + private static void Zero(byte[]? key, byte[]? sameAs) { if (key is not null && !ReferenceEquals(key, sameAs)) CryptographicOperations.ZeroMemory(key); } - /// - public async Task FinalizeAsync(CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(_keyPair); - ArgumentNullException.ThrowIfNull(_keystoreDataModel); - ArgumentNullException.ThrowIfNull(_configDataModel); - - _keyPair.MacKey.UseKey(macKey => - { - VaultParser.CalculateConfigMac(_configDataModel, macKey, _configDataModel.PayloadMac); - }); - - await _vaultWriter.WriteKeystoreAsync(_keystoreDataModel, cancellationToken); - await _vaultWriter.WriteConfigurationAsync(_configDataModel, cancellationToken); - - if (_writeShares) - await _vaultWriter.WriteComplementationAsync(_sharesDataModel, cancellationToken); - - using (_keyPair) - return new SecurityWrapper(_keyPair.CreateCopy(), _configDataModel); - } - /// public void Dispose() { diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs index 712b98595..989c32fe7 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs @@ -8,7 +8,6 @@ using SecureFolderFS.Core.Models; using SecureFolderFS.Core.VaultAccess; using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Models; namespace SecureFolderFS.Core.Routines.Operational @@ -22,6 +21,7 @@ internal sealed class ModifyCredentialsRoutine : IModifyCredentialsRoutine private VaultKeystoreDataModel? _existingV4KeystoreDataModel; private VaultKeystoreDataModel? _keystoreDataModel; private VaultConfigurationDataModel? _configDataModel; + private VaultConfigurationDataModel? _verifiedConfigDataModel; public ModifyCredentialsRoutine(VaultReader vaultReader, VaultWriter vaultWriter) { @@ -41,13 +41,50 @@ public void SetUnlockContract(IDisposable unlockContract) if (unlockContract is not IWrapper securityWrapper) throw new ArgumentException($"The {nameof(unlockContract)} is invalid."); - _keyPair = securityWrapper.Inner.KeyPair; + if (unlockContract is not IWrapper configurationWrapper) + throw new ArgumentException($"The {nameof(unlockContract)} does not carry a verified configuration."); + + // Operate on a private copy so this routine never disposes of the caller's unlock contract, + // keeping it valid for retries after a failed attempt and for the session after a successful one. + _keyPair = securityWrapper.Inner.KeyPair.CreateCopy(); + + // Retain the configuration whose MAC was verified during unlock, so the rewrite below + // is derived from authenticated data rather than from a fresh read of the vault directory + _verifiedConfigDataModel = configurationWrapper.Inner; } /// public void SetOptions(VaultOptions vaultOptions) { - _configDataModel = VaultConfigurationDataModel.V4FromVaultOptions(vaultOptions); + ArgumentNullException.ThrowIfNull(_verifiedConfigDataModel); + + // The new configuration is built on the model that was MAC-verified at unlock, never on the + // caller's re-read of sfconfig.cfg as that read is not validated anywhere. Without this, an + // attacker who rewrites the configuration on disk while the vault is unlocked gets this + // routine to stamp a genuine HMAC onto their downgrade (for example, ciphers set to CipherId.NONE) + EnsureMatchesVerified(nameof(vaultOptions.ContentCipherId), _verifiedConfigDataModel.ContentCipherId, vaultOptions.ContentCipherId); + EnsureMatchesVerified(nameof(vaultOptions.FileNameCipherId), _verifiedConfigDataModel.FileNameCipherId, vaultOptions.FileNameCipherId); + EnsureMatchesVerified(nameof(vaultOptions.NameEncodingId), _verifiedConfigDataModel.FileNameEncodingId, vaultOptions.NameEncodingId); + EnsureMatchesVerified(nameof(vaultOptions.VaultId), _verifiedConfigDataModel.Uid, vaultOptions.VaultId); + + // Only the fields that a credential change actually owns are taken from the caller; + // everything else - ciphers, encoding, version, vault ID, App Platform, shortening + // threshold - is carried over from the authenticated model unchanged + _configDataModel = _verifiedConfigDataModel with + { + AuthenticationMethod = vaultOptions.UnlockProcedure.ToString(), + ComplementGeneration = vaultOptions.ComplementGeneration, + RecycleBinSize = vaultOptions.RecycleBinSize, + PayloadMac = new byte[HMACSHA256.HashSizeInBytes] + }; + return; + + static void EnsureMatchesVerified(string field, string verified, string? supplied) + { + // A null value means the caller did not carry an opinion, so the verified one stands + if (supplied is not null && !string.Equals(verified, supplied, StringComparison.Ordinal)) + throw new CryptographicException($"The vault configuration on disk does not match the one authenticated at unlock ('{field}'). The vault directory may have been tampered with."); + } } /// @@ -55,7 +92,7 @@ public unsafe void SetCredentials(IKeyUsage passkey) { ArgumentNullException.ThrowIfNull(_keyPair); - // Recovery/unlock-contract flow: rotate to a fresh entropy value under the new passkey. + // Recovery/unlock-contract flow: re-key the keystore under the new passkey and a fresh salt. var salt = new byte[Cryptography.Constants.KeyTraits.SALT_LENGTH]; RandomNumberGenerator.Fill(salt); @@ -83,49 +120,23 @@ public unsafe void SetCredentials(IKeyUsage oldPasskey, IKeyUsage newPasskey, Ca var salt = new byte[Cryptography.Constants.KeyTraits.SALT_LENGTH]; RandomNumberGenerator.Fill(salt); - // Optional step-up flow: preserve existing entropy by decrypting it with the old passkey - // and re-encrypting it under the new passkey next to unchanged DEK and MAC keys. - // If old passkey material is unavailable (for example recovery-key driven rotation), - // the single-passkey overload rotates to fresh entropy and still yields a valid keystore. - Span softwareEntropy = stackalloc byte[32]; - try - { - fixed (byte* softwareEntropyPtr = softwareEntropy) - { - var state = (sePtr: (nint)softwareEntropyPtr, seLen: softwareEntropy.Length); - oldPasskey.UseKey(state, (oldKey, s) => - { - var se = new Span((byte*)s.sePtr, s.seLen); - VaultParser.DecryptSoftwareEntropy(oldKey, _existingV4KeystoreDataModel, se); - }); - } - - if (softwareEntropy.IsAllZeros()) - throw new CryptographicException("The old passkey material is unavailable."); + // Step-up flow: re-authenticate the old passkey against the existing keystore before + // re-keying. The DEK and MAC keys themselves are unchanged, so a successful verification + // is the only thing the old passkey is needed for; it throws when it does not match. + oldPasskey.UseKey(oldKey => VaultParser.VerifyKeystoreKey(oldKey, _existingV4KeystoreDataModel)); - fixed (byte* softwareEntropyPtr = softwareEntropy) + newPasskey.UseKey(newKey => + { + fixed (byte* newKeyPtr = newKey) { - var state = (sePtr: (nint)softwareEntropyPtr, seLen: softwareEntropy.Length); - newPasskey.UseKey(state, (newKey, s) => + var state = (nkPtr: (nint)newKeyPtr, nkLen: newKey.Length); + _keyPair.UseKeys(state, (dekKey, macKey, s) => { - fixed (byte* newKeyPtr = newKey) - { - var state2 = (nkPtr: (nint)newKeyPtr, nkLen: newKey.Length, outerState: state); - _keyPair.UseKeys(state2, (dekKey, macKey, s2) => - { - var nk = new ReadOnlySpan((byte*)s2.nkPtr, s2.nkLen); - var se = new Span((byte*)s2.outerState.sePtr, s2.outerState.seLen); - - _keystoreDataModel = VaultParser.ReEncryptKeystore(nk, dekKey, macKey, salt, se); - }); - } + var nk = new ReadOnlySpan((byte*)s.nkPtr, s.nkLen); + _keystoreDataModel = VaultParser.EncryptKeystore(nk, dekKey, macKey, salt); }); } - } - finally - { - CryptographicOperations.ZeroMemory(softwareEntropy); - } + }); } /// diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs index da6aadbd9..93782654f 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs @@ -12,11 +12,13 @@ using SecureFolderFS.Core.DataModels; using SecureFolderFS.Core.FileSystem.Buffers; using SecureFolderFS.Core.FileSystem.Extensions; +using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Core.Models; using SecureFolderFS.Core.VaultAccess; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Models; using SecureFolderFS.Shared.SecureStore; using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.Scanners; @@ -27,7 +29,6 @@ namespace SecureFolderFS.Core.Routines.Operational /// public sealed class RestoreRoutine : ICredentialsRoutine, IFinalizationRoutine { - private const int NO_EXTENSIONS_THRESHOLD = 5; private readonly IFolder _vaultFolder; private readonly VaultWriter _vaultWriter; @@ -37,6 +38,8 @@ public sealed class RestoreRoutine : ICredentialsRoutine, IFinalizationRoutine private VaultKeystoreDataModel? _keystoreDataModel; private VaultConfigurationDataModel? _configDataModel; private KeyPair? _keyPair; + private VaultRestorationParameters? _detectedParameters; + private bool _parametersConfirmed; public RestoreRoutine(IFolder vaultFolder, VaultWriter vaultWriter) { @@ -63,13 +66,65 @@ public async Task FinalizeAsync(CancellationToken cancellationToken { ArgumentNullException.ThrowIfNull(_keyPair); + // The configuration written here is signed with the vault's genuine MAC key and is therefore + // indistinguishable from one the user created. It must never be produced from parameters the + // user has not seen and accepted + var parameters = await DetectParametersAsync(cancellationToken); + if (!_parametersConfirmed) + throw new InvalidOperationException("The detected vault parameters must be confirmed before the configuration can be rebuilt."); + + // Regenerate config + var configDataModel = new VaultConfigurationDataModel() + { + AppPlatform = null, + AuthenticationMethod = Constants.Vault.Authentication.AUTH_RECOVERY_KEY_REQUIREMENT, // Recovery Key is required at first to recover the restored vault + ContentCipherId = parameters.ContentCipherId, + FileNameCipherId = parameters.FileNameCipherId, + FileNameEncodingId = parameters.FileNameEncodingId, + ShorteningThreshold = parameters.ShorteningThreshold, + RecycleBinSize = 0L, + Uid = Guid.NewGuid().ToString(), + Version = Constants.Vault.Versions.LATEST_VERSION, + PayloadMac = new byte[HMACSHA256.HashSizeInBytes] + }; + + // Calculate config MAC + _keyPair.MacKey.UseKey(macKey => + { + VaultParser.CalculateConfigMac(configDataModel, macKey, configDataModel.PayloadMac); + }); + + // Regenerate keystore + var keystore = GenerateKeystore(_keyPair); + + // Write the whole configuration + await _vaultWriter.WriteConfigurationAsync(configDataModel, cancellationToken); + await _vaultWriter.WriteKeystoreAsync(keystore, cancellationToken); + + return new SecurityWrapper(_keyPair.CreateCopy(), configDataModel); + } + + /// + /// Determines the cryptographic parameters of the vault by probing its contents. + /// + /// + /// The result must be presented to the user and confirmed through + /// before will rebuild the configuration. + /// + /// A that represents the asynchronous operation. Value is the detected parameters. + public async Task DetectParametersAsync(CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(_keyPair); + + if (_detectedParameters is not null) + return _detectedParameters; + var contentFolder = await _vaultFolder.GetFolderByNameAsync(Constants.Vault.Names.VAULT_CONTENT_FOLDERNAME, cancellationToken); var contentCryptIds = new[] { CipherId.AES_GCM, CipherId.XCHACHA20_POLY1305, CipherId.AES_CTR_HMAC }; string? foundContentCrypt = null; string? foundNameCrypt = null; string? foundEncoding = null; - var noExtensions = 0; var minSidecarContentLength = int.MaxValue; var hasShortenedNames = false; @@ -108,17 +163,18 @@ public async Task FinalizeAsync(CancellationToken cancellationToken continue; } - if (!item.Name.EndsWith(FileSystem.Constants.Names.ENCRYPTED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) - noExtensions++; - - // Check if we have enough files without extensions to be certain - if (noExtensions >= NO_EXTENSIONS_THRESHOLD) - (foundNameCrypt, foundEncoding) = (CipherId.NONE, CipherId.ENCODING_BASE4K); + // Vault infrastructure never carries an encrypted name, so it must take no part in the + // probe below. Treating a dirid.iv as a name that failed to decrypt is what allowed a + // vault with a handful of directories to conclude, with no attacker at all, that it + // had no filename encryption + if (PathHelpers.IsCoreName(item.Name)) + continue; // Find content crypt foundContentCrypt ??= await FindContentCryptAsync(file, _keyPair, contentCryptIds, cancellationToken); - // Find name crypt + // Find name crypt. The cipher is never inferred from what a name looks like. + // It is established only by an authenticated decryption that succeeds, so planting files cannot steer the result if (foundNameCrypt is null || foundEncoding is null) (foundNameCrypt, foundEncoding) = await FindNameCryptAsync(contentFolder, file, _keyPair, cancellationToken); @@ -127,43 +183,43 @@ public async Task FinalizeAsync(CancellationToken cancellationToken break; } - if (foundNameCrypt is null || foundEncoding is null || foundContentCrypt is null) - throw new InvalidOperationException("Could not find all required cryptographic components."); + // The content cipher is always established by trial decryption, so failing to find one + // means the vault holds nothing this routine can authenticate and the restore cannot proceed + if (foundContentCrypt is null) + throw new InvalidOperationException("Could not determine the content cipher of the vault."); + + // Every candidate name was probed with AES-SIV across both encodings and none authenticated. + // Having positively ruled out filename encryption, the names are stored in clear + if (foundNameCrypt is null || foundEncoding is null) + (foundNameCrypt, foundEncoding) = (CipherId.NONE, CipherId.ENCODING_BASE4K); // Determine shortening threshold from sidecar content, with fallback for missing sidecars var shorteningThreshold = minSidecarContentLength < int.MaxValue ? minSidecarContentLength : hasShortenedNames ? 220 : 0; - // Regenerate config - var configDataModel = new VaultConfigurationDataModel() + _detectedParameters = new VaultRestorationParameters() { - AppPlatform = null, - AuthenticationMethod = Constants.Vault.Authentication.AUTH_RECOVERY_KEY_REQUIREMENT, // Recovery Key is required at first to recover the restored vault ContentCipherId = foundContentCrypt, FileNameCipherId = foundNameCrypt, FileNameEncodingId = foundEncoding, ShorteningThreshold = shorteningThreshold, - RecycleBinSize = 0L, - Uid = Guid.NewGuid().ToString(), - Version = Constants.Vault.Versions.LATEST_VERSION, - PayloadMac = new byte[HMACSHA256.HashSizeInBytes] + IsFileNameEncrypted = !string.Equals(foundNameCrypt, CipherId.NONE, StringComparison.Ordinal) }; - // Calculate config MAC - _keyPair.MacKey.UseKey(macKey => - { - VaultParser.CalculateConfigMac(configDataModel, macKey, configDataModel.PayloadMac); - }); - - // Regenerate keystore - var keystore = GenerateKeystore(_keyPair); + return _detectedParameters; + } - // Write the whole configuration - await _vaultWriter.WriteConfigurationAsync(configDataModel, cancellationToken); - await _vaultWriter.WriteKeystoreAsync(keystore, cancellationToken); + /// + /// Accepts the parameters returned by , allowing the + /// configuration to be rebuilt from them. + /// + public void ConfirmParameters() + { + if (_detectedParameters is null) + throw new InvalidOperationException($"{nameof(DetectParametersAsync)} must be called before the parameters can be confirmed."); - return new SecurityWrapper(_keyPair.CreateCopy(), configDataModel); + _parametersConfirmed = true; } private unsafe VaultKeystoreDataModel GenerateKeystore(KeyPair keyPair) diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs index 65c651192..b9fc0e7c1 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs @@ -21,6 +21,7 @@ internal sealed class UnlockRoutine : ICredentialsRoutine private VaultKeystoreDataModel? _keystoreDataModel; private VaultConfigurationDataModel? _configDataModel; private VaultSharesDataModel? _sharesDataModel; + private byte[]? _passkeyBytes; private SecureKey? _dekKey; private SecureKey? _macKey; @@ -43,16 +44,30 @@ public void SetCredentials(IKeyUsage passkey) ArgumentNullException.ThrowIfNull(_configDataModel); ArgumentNullException.ThrowIfNull(_keystoreDataModel); - var authenticationMethod = AuthenticationMethod.FromString(_configDataModel.AuthenticationMethod); - var derived = string.IsNullOrWhiteSpace(authenticationMethod.Complementation) - ? passkey.UseKey(key => VaultParser.DeriveKeystore(key, _keystoreDataModel)) - : DeriveComplementedKeystore(passkey, authenticationMethod); + // The Argon2id is asynchronous, and SetCredentials is synchronous, thus blocking + // on the KDF's worker tasks deadlocks single-threaded runtimes (browser WASM). Keep a + // copy of the passkey and derive in FinalizeAsync, where the KDF can be awaited. + _passkeyBytes = passkey.UseKey(static key => key.ToArray()); + } + + private async Task<(byte[] dekKey, byte[] macKey)> DeriveFromComplementSecretAsync(byte[] passkeyBytes, string primaryMethodId) + { + ArgumentNullException.ThrowIfNull(_configDataModel); + ArgumentNullException.ThrowIfNull(_keystoreDataModel); - _dekKey = SecureKey.TakeOwnership(derived.dekKey); - _macKey = SecureKey.TakeOwnership(derived.macKey); + var complementSecret = new byte[32]; + try + { + VaultParser.DeriveComplementKey(passkeyBytes, _configDataModel.Uid, primaryMethodId, _configDataModel.ComplementGeneration, complementSecret); + return await VaultParser.DeriveKeystoreAsync(complementSecret, _keystoreDataModel); + } + finally + { + CryptographicOperations.ZeroMemory(complementSecret); + } } - private (byte[] dekKey, byte[] macKey) DeriveComplementedKeystore(IKeyUsage passkey, AuthenticationMethod authenticationMethod) + private async Task<(byte[] dekKey, byte[] macKey)> DeriveComplementedKeystoreAsync(byte[] passkeyBytes, AuthenticationMethod authenticationMethod) { ArgumentNullException.ThrowIfNull(_configDataModel); ArgumentNullException.ThrowIfNull(_keystoreDataModel); @@ -62,19 +77,7 @@ public void SetCredentials(IKeyUsage passkey) try { - return passkey.UseKey(key => - { - Span complementSecret = stackalloc byte[32]; - try - { - VaultParser.DeriveComplementKey(key, _configDataModel.Uid, primaryMethodId, complementSecret); - return VaultParser.DeriveKeystore(complementSecret, _keystoreDataModel); - } - finally - { - CryptographicOperations.ZeroMemory(complementSecret); - } - }); + return await DeriveFromComplementSecretAsync(passkeyBytes, primaryMethodId); } catch (CryptographicException ex) { @@ -95,8 +98,8 @@ public void SetCredentials(IKeyUsage passkey) byte[]? complementSecret = null; try { - complementSecret = passkey.UseKey(key => VaultParser.UnwrapComplementSecret(key, _configDataModel.Uid, share)); - return VaultParser.DeriveKeystore(complementSecret, _keystoreDataModel); + complementSecret = VaultParser.UnwrapComplementSecret(passkeyBytes, _configDataModel.Uid, share, _configDataModel.ComplementGeneration); + return await VaultParser.DeriveKeystoreAsync(complementSecret, _keystoreDataModel); } catch (CryptographicException ex) { @@ -109,17 +112,47 @@ public void SetCredentials(IKeyUsage passkey) } } + try + { + // Resilience for an interrupted complementation change. The modify routine orders its two + // mutations so that a crash always leaves the config claiming complementation while the + // keystore is still keyed under the raw primary (remove: keystore written first; add: + // config written first). A direct derivation recovers from exactly that window. It is an + // authenticated attempt that only succeeds if the keystore is actually keyed this way, so + // it never weakens the normal path. + return await VaultParser.DeriveKeystoreAsync(passkeyBytes, _keystoreDataModel); + } + catch (CryptographicException ex) + { + lastException = ex; + } + throw lastException ?? new CryptographicException("The complemented credentials could not unlock this vault."); } /// public async Task FinalizeAsync(CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(_dekKey); - ArgumentNullException.ThrowIfNull(_macKey); + ArgumentNullException.ThrowIfNull(_passkeyBytes); ArgumentNullException.ThrowIfNull(_configDataModel); ArgumentNullException.ThrowIfNull(_keystoreDataModel); + try + { + var authenticationMethod = AuthenticationMethod.FromString(_configDataModel.AuthenticationMethod); + var derived = string.IsNullOrWhiteSpace(authenticationMethod.Complementation) + ? await VaultParser.DeriveKeystoreAsync(_passkeyBytes, _keystoreDataModel) + : await DeriveComplementedKeystoreAsync(_passkeyBytes, authenticationMethod); + + _dekKey = SecureKey.TakeOwnership(derived.dekKey); + _macKey = SecureKey.TakeOwnership(derived.macKey); + } + finally + { + CryptographicOperations.ZeroMemory(_passkeyBytes); + _passkeyBytes = null; + } + using (_dekKey) using (_macKey) { @@ -136,6 +169,12 @@ public async Task FinalizeAsync(CancellationToken cancellationToken /// public void Dispose() { + if (_passkeyBytes is not null) + { + CryptographicOperations.ZeroMemory(_passkeyBytes); + _passkeyBytes = null; + } + _dekKey?.Dispose(); _macKey?.Dispose(); } diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs index d12da28f4..31ef10fc3 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs @@ -33,19 +33,30 @@ public ICreationRoutine CreateVault() return new CreationRoutine(_vaultFolder, VaultWriter); } + public AppPlatformCreationRoutine CreateAppPlatformVault() + { + return new AppPlatformCreationRoutine(_vaultFolder, VaultWriter); + } + public ICredentialsRoutine UnlockVault() { CheckVaultValidation(); return new UnlockRoutine(VaultReader); } + public ICredentialsRoutine UnlockAppPlatformVault() + { + CheckVaultValidation(); + return new AppPlatformUnlockRoutine(VaultReader); + } + public ICredentialsRoutine RecoverVault() { CheckVaultValidation(); return new RecoverRoutine(VaultReader); } - public ICredentialsRoutine RestoreVault() + public RestoreRoutine RestoreVault() { // In the case of restoring the validation is not triggered since the vault is expected to be in an invalid state return new RestoreRoutine(_vaultFolder, VaultWriter); diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs index b1d4203d4..2436d4d6a 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; +using System.Threading.Tasks; using SecureFolderFS.Core.Cryptography.Cipher; using SecureFolderFS.Core.Cryptography.Helpers; using SecureFolderFS.Core.DataModels; @@ -30,11 +31,10 @@ public static void CalculateConfigMac(VaultConfigurationDataModel configDataMode hmacSha256.AppendData(BitConverter.GetBytes(configDataModel.ShorteningThreshold)); // ShorteningThreshold hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.FileNameEncodingId)); // FileNameEncodingId hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.Uid)); // Uid - // hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.ServerUrl ?? string.Empty)); - // hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.VaultResource ?? string.Empty)); - // hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.Organization ?? string.Empty)); - // hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.AccessTokenEndpoint ?? string.Empty)); - // hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.DeviceRegistrationEndpoint ?? string.Empty)); + if (configDataModel.AppPlatform?.ServerUrl is { } serverUrl) + hmacSha256.AppendData(Encoding.UTF8.GetBytes(serverUrl)); // AppPlatform.ServerUrl + if (configDataModel.ComplementGeneration > 0) + hmacSha256.AppendData(BitConverter.GetBytes(configDataModel.ComplementGeneration)); // ComplementGeneration (omitted at gen 0 for back-compat) hmacSha256.AppendFinalData(Encoding.UTF8.GetBytes(configDataModel.AuthenticationMethod)); // AuthenticationMethod // Fill the hash to payload @@ -43,86 +43,105 @@ public static void CalculateConfigMac(VaultConfigurationDataModel configDataMode /// /// Derives DEK and MAC keys from provided credentials for a vault. - /// Decrypts using the - /// raw passkey, then mixes it into the Argon2id input via HKDF-Extract before - /// deriving the KEK. This raises the quantum security floor to 256 bits regardless - /// of the entropy of the auth factor feeding the passkey. + /// The passkey is stretched with Argon2id to produce the KEK, which unwraps the stored + /// keys. Argon2id is the only step between the passkey and the KEK, so the sole way to + /// test a candidate passkey against the keystore is the RFC3394 unwrap. /// /// The passkey credential that combines all active auth factor outputs. /// The keystore that holds wrapped keys. /// A tuple containing the DEK and MAC keys respectively. - [SkipLocalsInit] public static (byte[] dekKey, byte[] macKey) DeriveKeystore(ReadOnlySpan passkey, VaultKeystoreDataModel keystoreDataModel) { ArgumentNullException.ThrowIfNull(keystoreDataModel.Salt); - ArgumentNullException.ThrowIfNull(keystoreDataModel.EncryptedSoftwareEntropy); - ArgumentNullException.ThrowIfNull(keystoreDataModel.SoftwareEntropyNonce); - ArgumentNullException.ThrowIfNull(keystoreDataModel.SoftwareEntropyTag); - - var dekKey = new byte[Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH]; - var macKey = new byte[Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH]; - - // Step 1: Decrypt SoftwareEntropy using a key derived from the raw passkey. - // The bootstrap key is derived from the passkey alone (not the augmented key) - // so that recovering SoftwareEntropy always requires all active auth factors. - Span bootstrapKey = stackalloc byte[32]; - HKDF.DeriveKey( - HashAlgorithmName.SHA256, - passkey, - bootstrapKey, - keystoreDataModel.Salt, // Salt ties the bootstrap key to this specific keystore - "SFFSv4-EntropyBootstrap-v1"u8); - Span softwareEntropy = stackalloc byte[keystoreDataModel.EncryptedSoftwareEntropy.Length]; - using (var aes = new AesGcm(bootstrapKey, 16)) + Span kek = stackalloc byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; + try + { + Argon2id.DeriveKey(passkey, keystoreDataModel.Salt, kek); + return UnwrapKeys(kek, keystoreDataModel); + } + finally { - aes.Decrypt( - keystoreDataModel.SoftwareEntropyNonce, - keystoreDataModel.EncryptedSoftwareEntropy, - keystoreDataModel.SoftwareEntropyTag, - softwareEntropy); + CryptographicOperations.ZeroMemory(kek); } + } + + /// + /// + /// The awaitable form exists because the Argon2id step must not block the calling thread on single-threaded runtimes (browser WASM). + ///
+ /// Caller retains ownership of . + ///
+ public static async Task<(byte[] dekKey, byte[] macKey)> DeriveKeystoreAsync(byte[] passkey, VaultKeystoreDataModel keystoreDataModel) + { + ArgumentNullException.ThrowIfNull(keystoreDataModel.Salt); + var kek = new byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; try { - // Step 2: Mix passkey and SoftwareEntropy via HKDF-Extract. - // passkey is IKM; SoftwareEntropy is salt. - // Breaking either alone is insufficient to reproduce the augmented key. - Span augmentedPasskey = stackalloc byte[32]; - HKDF.DeriveKey( - HashAlgorithmName.SHA256, - passkey, - augmentedPasskey, - softwareEntropy, - "SFFSv4-AugmentedPasskey-v1"u8); + await Argon2id.DeriveKeyAsync(passkey, keystoreDataModel.Salt, kek).ConfigureAwait(false); + return UnwrapKeys(kek, keystoreDataModel); + } + finally + { + CryptographicOperations.ZeroMemory(kek); + } + } + + /// + /// Confirms that opens , without + /// returning the unwrapped keys. Used by credential- and complementation-change routines to + /// authenticate a supplied credential against the existing keystore before re-keying it. + /// + /// The passkey credential to verify. + /// The existing keystore to verify against. + public static void VerifyKeystoreKey(ReadOnlySpan passkey, VaultKeystoreDataModel keystoreDataModel) + { + var (dekKey, macKey) = DeriveKeystore(passkey, keystoreDataModel); + CryptographicOperations.ZeroMemory(dekKey); + CryptographicOperations.ZeroMemory(macKey); + } - // Step 3: Derive KEK from the augmented passkey - Span kek = stackalloc byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; - Argon2id.DeriveKey(augmentedPasskey, keystoreDataModel.Salt, kek); + /// + /// Unwraps the stored DEK and MAC keys with the supplied KEK. The RFC3394 unwrap is + /// integrity-checked, so a wrong KEK throws instead of yielding garbage keys. + /// + private static (byte[] dekKey, byte[] macKey) UnwrapKeys(ReadOnlySpan kek, VaultKeystoreDataModel keystoreDataModel) + { + // A keystore missing either wrapped key would otherwise reach the unwrap as an empty span, + // whose failure mode differs per backend. Unlock's fallback chain only catches + // CryptographicException, so fail here with a definite exception type instead. + ArgumentNullException.ThrowIfNull(keystoreDataModel.WrappedDekKey); + ArgumentNullException.ThrowIfNull(keystoreDataModel.WrappedMacKey); - // Step 4: Unwrap keys + var dekKey = new byte[Cryptography.Constants.KeyTraits.DEK_KEY_LENGTH]; + var macKey = new byte[Cryptography.Constants.KeyTraits.MAC_KEY_LENGTH]; + try + { using var rfc3394 = new Rfc3394KeyWrap(); rfc3394.UnwrapKey(keystoreDataModel.WrappedDekKey, kek, dekKey); rfc3394.UnwrapKey(keystoreDataModel.WrappedMacKey, kek, macKey); + + return (dekKey, macKey); } - finally + catch { - CryptographicOperations.ZeroMemory(softwareEntropy); + CryptographicOperations.ZeroMemory(dekKey); + CryptographicOperations.ZeroMemory(macKey); + throw; } - - return (dekKey, macKey); } /// /// Encrypts cryptographic keys and creates a new instance of . - /// Generates and encrypts a fresh - /// which is mixed into Argon2id input at unlock time to raise the quantum security floor. + /// The KEK is derived from the passkey with Argon2id alone; the DEK and MAC keys it wraps are + /// already full-width CSPRNG values, so no additional key material is stored alongside them. /// /// The passkey credential that combines all active auth factor outputs. /// The DEK key. /// The MAC key. /// The salt used during KEK derivation. - /// A new instance of containing the encrypted cryptographic keys and entropy. + /// A new instance of containing the encrypted cryptographic keys. [SkipLocalsInit] public static VaultKeystoreDataModel EncryptKeystore( ReadOnlySpan passkey, @@ -130,81 +149,48 @@ public static VaultKeystoreDataModel EncryptKeystore( ReadOnlySpan macKey, byte[] salt) { - // Step 1: Generate fresh SoftwareEntropy (256-bit CSPRNG) - Span softwareEntropy = stackalloc byte[32]; - RandomNumberGenerator.Fill(softwareEntropy); - - return EncryptKeystoreWithEntropy(passkey, dekKey, macKey, salt, softwareEntropy); - } - - /// - /// Re-encrypts cryptographic keys into a new while - /// preserving the provided . - /// This is an optional credential-rotation path when the previous passkey is available. - /// - /// The new passkey credential. - /// The DEK key (unchanged from the existing keystore). - /// The MAC key (unchanged from the existing keystore). - /// A freshly generated salt for the new keystore. - /// The plaintext SoftwareEntropy recovered from the old keystore. - /// A new with re-encrypted keys and entropy. - [SkipLocalsInit] - public static VaultKeystoreDataModel ReEncryptKeystore( - ReadOnlySpan passkey, - ReadOnlySpan dekKey, - ReadOnlySpan macKey, - byte[] salt, - ReadOnlySpan existingSoftwareEntropy) - { - return EncryptKeystoreWithEntropy(passkey, dekKey, macKey, salt, existingSoftwareEntropy); - } - - /// - /// Decrypts the from an existing - /// keystore using the previous passkey. - /// This is only required for preserve-entropy rotation; fresh-entropy rotation uses - /// . - /// - /// The current (old) passkey. - /// The existing V4 keystore. - /// The destination span to fill with the decrypted entropy (must be 32 bytes). - public static void DecryptSoftwareEntropy( - ReadOnlySpan passkey, - VaultKeystoreDataModel keystoreDataModel, - Span softwareEntropy) - { - ArgumentNullException.ThrowIfNull(keystoreDataModel.Salt); - ArgumentNullException.ThrowIfNull(keystoreDataModel.EncryptedSoftwareEntropy); - ArgumentNullException.ThrowIfNull(keystoreDataModel.SoftwareEntropyNonce); - ArgumentNullException.ThrowIfNull(keystoreDataModel.SoftwareEntropyTag); + Span kek = stackalloc byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; + try + { + // Derive the KEK from the passkey, then wrap the keys under it. Mirrors DeriveKeystore. + Argon2id.DeriveKey(passkey, salt, kek); - Span bootstrapKey = stackalloc byte[32]; - HKDF.DeriveKey( - HashAlgorithmName.SHA256, - passkey, - bootstrapKey, - keystoreDataModel.Salt, - "SFFSv4-EntropyBootstrap-v1"u8); + using var rfc3394 = new Rfc3394KeyWrap(); + var wrappedDekKey = rfc3394.WrapKey(dekKey, kek); + var wrappedMacKey = rfc3394.WrapKey(macKey, kek); - using var aes = new AesGcm(bootstrapKey, 16); - aes.Decrypt( - keystoreDataModel.SoftwareEntropyNonce, - keystoreDataModel.EncryptedSoftwareEntropy, - keystoreDataModel.SoftwareEntropyTag, - softwareEntropy); + return new() + { + WrappedDekKey = wrappedDekKey, + WrappedMacKey = wrappedMacKey, + Salt = salt + }; + } + finally + { + CryptographicOperations.ZeroMemory(kek); + } } public static void DeriveComplementKey( ReadOnlySpan passkey, string vaultId, string authenticationMethodId, + int generation, Span complementKey) { ArgumentException.ThrowIfNullOrWhiteSpace(vaultId); ArgumentException.ThrowIfNullOrWhiteSpace(authenticationMethodId); + ArgumentOutOfRangeException.ThrowIfNegative(generation); var salt = Encoding.UTF8.GetBytes(vaultId); - var info = Encoding.UTF8.GetBytes(authenticationMethodId); + + // Generation 0 reproduces the legacy derivation (no suffix); any later generation mixes in + // the counter so rotating it produces an entirely different complement domain, invalidating + // shares and keystore material issued under previous generations. + var info = generation > 0 + ? Encoding.UTF8.GetBytes($"{authenticationMethodId}|gen={generation}") + : Encoding.UTF8.GetBytes(authenticationMethodId); HKDF.DeriveKey( HashAlgorithmName.SHA256, @@ -218,20 +204,20 @@ public static VaultShareDataModel WrapComplementSecret( ReadOnlySpan complementSecret, ReadOnlySpan wrappingKeyMaterial, string vaultId, - string authenticationMethodId) + string authenticationMethodId, + int generation) { Span complementWrapKey = stackalloc byte[32]; try { - DeriveComplementKey(wrappingKeyMaterial, vaultId, authenticationMethodId, complementWrapKey); + DeriveComplementKey(wrappingKeyMaterial, vaultId, authenticationMethodId, generation, complementWrapKey); var nonce = new byte[12]; var tag = new byte[16]; var wrapped = new byte[complementSecret.Length]; RandomNumberGenerator.Fill(nonce); - using (var aes = new AesGcm(complementWrapKey, 16)) - aes.Encrypt(nonce, complementSecret, wrapped, tag); + AesGcm256.Encrypt(complementSecret, complementWrapKey, nonce, tag, wrapped, ReadOnlySpan.Empty); return new() { @@ -250,7 +236,8 @@ public static VaultShareDataModel WrapComplementSecret( public static byte[] UnwrapComplementSecret( ReadOnlySpan wrappingKeyMaterial, string vaultId, - VaultShareDataModel shareDataModel) + VaultShareDataModel shareDataModel, + int generation) { ArgumentNullException.ThrowIfNull(shareDataModel.AuthenticationMethodId); ArgumentNullException.ThrowIfNull(shareDataModel.Nonce); @@ -260,11 +247,16 @@ public static byte[] UnwrapComplementSecret( Span complementWrapKey = stackalloc byte[32]; try { - DeriveComplementKey(wrappingKeyMaterial, vaultId, shareDataModel.AuthenticationMethodId, complementWrapKey); + DeriveComplementKey(wrappingKeyMaterial, vaultId, shareDataModel.AuthenticationMethodId, generation, complementWrapKey); var complementSecret = new byte[shareDataModel.WrappedComplementSecret.Length]; - using var aes = new AesGcm(complementWrapKey, 16); - aes.Decrypt(shareDataModel.Nonce, shareDataModel.WrappedComplementSecret, shareDataModel.Tag, complementSecret); + AesGcm256.Decrypt( + shareDataModel.WrappedComplementSecret, + complementWrapKey, + shareDataModel.Nonce, + shareDataModel.Tag, + complementSecret, + ReadOnlySpan.Empty); return complementSecret; } @@ -273,68 +265,5 @@ public static byte[] UnwrapComplementSecret( CryptographicOperations.ZeroMemory(complementWrapKey); } } - - /// - /// Shared implementation for both and . - /// Encrypts the provided entropy under the passkey and wraps DEK/MAC under the augmented KEK. - /// - [SkipLocalsInit] - private static VaultKeystoreDataModel EncryptKeystoreWithEntropy( - ReadOnlySpan passkey, - ReadOnlySpan dekKey, - ReadOnlySpan macKey, - byte[] salt, - ReadOnlySpan softwareEntropy) - { - // Step 1: Encrypt SoftwareEntropy under a bootstrap key derived from the raw passkey. - // Using the raw passkey (not the augmented one) means decrypting entropy - // always requires all active auth factors — same guarantee at both creation and unlock. - Span bootstrapKey = stackalloc byte[32]; - HKDF.DeriveKey( - HashAlgorithmName.SHA256, - passkey, - bootstrapKey, - salt, - "SFFSv4-EntropyBootstrap-v1"u8); - - var entropyNonce = new byte[12]; - var entropyTag = new byte[16]; - var encryptedEntropy = new byte[softwareEntropy.Length]; - RandomNumberGenerator.Fill(entropyNonce); - - using (var aes = new AesGcm(bootstrapKey, 16)) - { - aes.Encrypt(entropyNonce, softwareEntropy, encryptedEntropy, entropyTag); - } - - // Step 2: Augment passkey with SoftwareEntropy via HKDF-Extract before Argon2id. - // This is the same derivation performed at unlock in V4DeriveKeystore. - Span augmentedPasskey = stackalloc byte[32]; - HKDF.DeriveKey( - HashAlgorithmName.SHA256, - passkey, - augmentedPasskey, - softwareEntropy, - "SFFSv4-AugmentedPasskey-v1"u8); - - // Step 3: Derive KEK from augmented passkey - Span kek = stackalloc byte[Cryptography.Constants.KeyTraits.ARGON2_KEK_LENGTH]; - Argon2id.DeriveKey(augmentedPasskey, salt, kek); - - // Step 4: Wrap keys - using var rfc3394 = new Rfc3394KeyWrap(); - var wrappedDekKey = rfc3394.WrapKey(dekKey, kek); - var wrappedMacKey = rfc3394.WrapKey(macKey, kek); - - return new() - { - WrappedDekKey = wrappedDekKey, - WrappedMacKey = wrappedMacKey, - Salt = salt, - EncryptedSoftwareEntropy = encryptedEntropy, - SoftwareEntropyNonce = entropyNonce, - SoftwareEntropyTag = entropyTag - }; - } } } diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs index 54d5d9175..a622cf73d 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs @@ -85,20 +85,27 @@ public async Task WriteAuthenticationAsync(string fileName, TCapabi private async Task WriteDataAsync(IFile? file, TData? data, CancellationToken cancellationToken) { - if (file is null) + if (file is null || data is null) return; + // Serialize fully into memory BEFORE touching the destination. The destination is truncated + // in place (the storage abstraction offers no atomic replace), so serializing first ensures a + // serialization or allocation failure can never leave a truncated/empty keystore or configuration. + byte[] payload; + await using (var serializedData = await _serializer.SerializeAsync(data, cancellationToken)) + await using (var buffer = new MemoryStream()) + { + await serializedData.CopyToAsync(buffer, cancellationToken); + payload = buffer.ToArray(); + } + // Open a stream to the data file await using var fileStream = await file.OpenStreamAsync(FileAccess.Write, cancellationToken); - // Clear contents if opened from an existing file + // Clear contents if opened from an existing file, then write the fully-materialized payload in one pass fileStream.TrySetLength(0L); - - if (data is not null) - { - await using var serializedData = await _serializer.SerializeAsync(data, cancellationToken); - await serializedData.CopyToAsync(fileStream, cancellationToken); - } + await fileStream.WriteAsync(payload, cancellationToken); + await fileStream.FlushAsync(cancellationToken); } } } diff --git a/src/Platforms/Directory.Build.props b/src/Platforms/Directory.Build.props index 2e6891c2c..dfcd184aa 100644 --- a/src/Platforms/Directory.Build.props +++ b/src/Platforms/Directory.Build.props @@ -31,6 +31,17 @@ false + + + + $(MSBuildThisFileDirectory)..\Sdk\SecureFolderFS.Sdk.AppPlatform\SecureFolderFS.Sdk.AppPlatform.csproj + + + + + $(DefineConstants);APP_PLATFORM_PRESENT + + @@ -61,12 +72,6 @@ 10.14 - - - true - 17.0 - - true diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props index f8fb1cb59..42a30ae65 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -13,17 +13,22 @@ + + + + + @@ -39,12 +44,12 @@ - - + +
-
\ No newline at end of file +
diff --git a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs new file mode 100644 index 000000000..6d39fde6c --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs @@ -0,0 +1,357 @@ +using System.IO.Pipes; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace SecureFolderFS.Cli; + +/// +/// Talks to a running SecureFolderFS instance over the local integration API. +/// +internal sealed class AppApiClient : IAsyncDisposable +{ + private const string CLIENT_NAME = "SecureFolderFS CLI"; + private const int PROTOCOL_VERSION = 1; + + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30); + + private static readonly TimeSpan PairTimeout = TimeSpan.FromMinutes(1); + + private readonly Stream _stream; + private readonly StreamReader _reader; + private readonly StreamWriter _writer; + private long _nextId = 1; + + private AppApiClient(Stream stream) + { + _stream = stream; + _reader = new StreamReader(stream, Encoding.UTF8); + // Newline-delimited JSON + _writer = new StreamWriter(stream, new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" }; + } + + /// + /// Connects to the running application, pairing if this is the first use. + /// + /// The API was unreachable, or pairing was refused. + public static async Task ConnectAsync(CancellationToken cancellationToken = default) + { + var endpoint = ApiEndpoint.TryRead() + ?? throw new AppApiException("SecureFolderFS does not appear to be installed for this user."); + + if (!endpoint.Enabled) + throw new AppApiException("App integrations are turned off. Enable them in SecureFolderFS under Settings > Preferences."); + + if (PROTOCOL_VERSION < endpoint.ProtocolMin || PROTOCOL_VERSION > endpoint.ProtocolMax) + throw new AppApiException("SecureFolderFS speaks a protocol version this build does not support."); + + var stream = await endpoint.OpenAsync(ConnectTimeout, cancellationToken) + ?? throw new AppApiException("SecureFolderFS is not running."); + + var client = new AppApiClient(stream); + try + { + await client.HandshakeAsync(cancellationToken); + return client; + } + catch + { + await client.DisposeAsync(); + throw; + } + } + + private async Task HandshakeAsync(CancellationToken cancellationToken) + { + var token = TokenStore.TryRead(); + var hello = await CallAsync("hello", new + { + protocolMin = PROTOCOL_VERSION, + protocolMax = PROTOCOL_VERSION, + clientName = CLIENT_NAME, + token + }, cancellationToken); + + if (hello.GetProperty("state").GetString() == "paired") + return; + + // A stored token that no longer authenticates means the user revoked us. + if (token is not null) + TokenStore.Clear(); + + // 'pair' raises a consent dialog and waits for the user to answer it + var pair = await CallAsync("pair", new { scopes = new[] { "vaults.read", "vaults.trigger" } }, cancellationToken, PairTimeout); + TokenStore.Write(pair.GetProperty("token").GetString()!); + } + + /// + /// Lists every vault visible to integrations. + /// + public async Task> ListVaultsAsync(CancellationToken cancellationToken = default) + { + var result = await CallAsync("vaults.list", null, cancellationToken); + + var vaults = new List(); + foreach (var vault in result.GetProperty("vaults").EnumerateArray()) + { + vaults.Add(new ApiVault( + vault.GetProperty("id").GetString() ?? string.Empty, + vault.GetProperty("name").GetString() ?? string.Empty, + vault.GetProperty("state").GetString() ?? string.Empty, + vault.TryGetProperty("mountPath", out var mountPath) ? mountPath.GetString() : null)); + } + + return vaults; + } + + /// + /// Asks the application to show its unlock prompt for a vault. Returns the reported status. + /// + public Task RequestUnlockAsync(string vaultId, CancellationToken cancellationToken = default) + => CallForStatusAsync("vaults.requestUnlock", vaultId, cancellationToken); + + /// + /// Locks an unlocked vault. Returns the reported status. + /// + public Task LockAsync(string vaultId, CancellationToken cancellationToken = default) + => CallForStatusAsync("vaults.lock", vaultId, cancellationToken); + + private async Task CallForStatusAsync(string method, string vaultId, CancellationToken cancellationToken) + { + var result = await CallAsync(method, new { vaultId }, cancellationToken); + return result.TryGetProperty("status", out var status) ? status.GetString() ?? "ok" : "ok"; + } + + private async Task CallAsync(string method, object? parameters, CancellationToken cancellationToken, TimeSpan? timeout = null) + { + var id = _nextId++; + await _writer.WriteLineAsync(JsonSerializer.Serialize(new { id, method, @params = parameters })); + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout ?? RequestTimeout); + + while (true) + { + var line = await _reader.ReadLineAsync(timeoutSource.Token) + ?? throw new AppApiException($"SecureFolderFS closed the connection during '{method}'."); + + if (line.Length == 0) + continue; + + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + + // Notifications carry no id, and replies to other requests may interleave with this one. + if (!root.TryGetProperty("id", out var replyId) || replyId.ValueKind == JsonValueKind.Null || replyId.GetInt64() != id) + continue; + + if (root.TryGetProperty("error", out var error)) + throw new AppApiException(DescribeError(method, error)); + + return root.TryGetProperty("result", out var result) ? result.Clone() : default; + } + } + + private static string DescribeError(string method, JsonElement error) + { + var code = error.TryGetProperty("code", out var c) ? c.GetString() : null; + var message = error.TryGetProperty("message", out var m) ? m.GetString() : null; + var retryAfterMs = error.TryGetProperty("retryAfterMs", out var r) && r.ValueKind == JsonValueKind.Number ? r.GetInt32() : 0; + + return code switch + { + "pairing_denied" => "SecureFolderFS declined the connection.", + "pairing_unavailable" => "SecureFolderFS is not accepting a pairing request right now. Try again shortly.", + "forbidden_scope" => "This tool was not granted permission for that action.", + "rate_limited" => $"Rate limited; retry in {retryAfterMs / 1000}s.", + "not_found" => "No such vault.", + _ => $"'{method}' failed ({code}): {message}" + }; + } + + /// + public async ValueTask DisposeAsync() + { + _reader.Dispose(); + await _writer.DisposeAsync(); + await _stream.DisposeAsync(); + } + + /// + /// A vault as reported by the API. + /// + internal sealed record ApiVault(string Id, string Name, string State, string? MountPath); + + /// + /// Locates and connects to the endpoint by reading the persistent endpoint file, exactly as the + /// protocol specification describes. + /// + private sealed record ApiEndpoint(string Transport, string Address, bool Enabled, int ProtocolMin, int ProtocolMax) + { + public static ApiEndpoint? TryRead() + { + try + { + var path = Path.Combine(GetPersistentDirectory(), "api-endpoint.json"); + if (!File.Exists(path)) + return null; + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var root = document.RootElement; + + return new ApiEndpoint( + root.GetProperty("transport").GetString() ?? string.Empty, + root.GetProperty("address").GetString() ?? string.Empty, + root.TryGetProperty("enabled", out var enabled) && enabled.GetBoolean(), + root.TryGetProperty("protocolMin", out var min) ? min.GetInt32() : 0, + root.TryGetProperty("protocolMax", out var max) ? max.GetInt32() : 0); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Opens the transport, or when nothing is listening. + /// + public async Task OpenAsync(TimeSpan connectTimeout, CancellationToken cancellationToken) + { + try + { + return Transport switch + { + "namedPipe" => await ConnectPipeAsync(Address, connectTimeout, cancellationToken), + "unixSocket" => await ConnectSocketAsync(Address, cancellationToken), + _ => null + }; + } + catch (Exception ex) when (ex is SocketException or IOException or TimeoutException or UnauthorizedAccessException) + { + return null; + } + } + + private static async Task ConnectPipeAsync(string address, TimeSpan connectTimeout, CancellationToken cancellationToken) + { + // On Windows the advertised address is the bare pipe name; tolerate a \\.\pipe\ prefix too. + var pipeName = address.StartsWith(@"\\.\pipe\", StringComparison.OrdinalIgnoreCase) + ? address[@"\\.\pipe\".Length..] + : address; + + var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(connectTimeout); + + await pipe.ConnectAsync(timeout.Token); + return pipe; + } + catch + { + await pipe.DisposeAsync(); + throw; + } + } + + private static async Task ConnectSocketAsync(string address, CancellationToken cancellationToken) + { + if (!File.Exists(address)) + return null; + + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(address), cancellationToken); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + + private static string GetPersistentDirectory() + { + if (OperatingSystem.IsWindows()) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "SecureFolderFS"); + } + + if (OperatingSystem.IsMacOS()) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", "Application Support", "SecureFolderFS"); + } + + var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (string.IsNullOrEmpty(configHome)) + configHome = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); + + return Path.Combine(configHome, "securefolderfs"); + } + } + + /// + /// Persists the pairing token for this tool. + /// + private static class TokenStore + { + private static string FilePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".securefolderfs", + "cli-api-token"); + + public static string? TryRead() + { + try + { + return File.Exists(FilePath) ? File.ReadAllText(FilePath).Trim() : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + public static void Write(string token) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + File.WriteAllText(FilePath, token); + + // The token authorizes this tool, so keep it readable only by its owner. + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(FilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Losing the token only means pairing again next time. + } + } + + public static void Clear() + { + try + { + File.Delete(FilePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort. + } + } + } +} + +/// +/// Represents a failure talking to the local integration API. +/// +internal sealed class AppApiException(string message) : Exception(message); diff --git a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs index 34aab586f..c8c4fb827 100644 --- a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs +++ b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs @@ -51,6 +51,10 @@ public static int HandleException(Exception ex, IConsole console, CliGlobalOptio { switch (ex) { + case AppApiException: + CliOutput.Error(console, options, ex.Message); + return CliExitCodes.GeneralError; + case CryptographicException: case FormatException: CliOutput.Error(console, options, ex.Message); diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs new file mode 100644 index 000000000..f13363eec --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs @@ -0,0 +1,103 @@ +using CliFx; +using CliFx.Binding; +using CliFx.Infrastructure; + +namespace SecureFolderFS.Cli.Commands; + +/// +/// Lists the vaults known to a running SecureFolderFS instance. +/// +[Command("app vaults", Description = "List vaults from a running SecureFolderFS instance.")] +public sealed partial class AppVaultsCommand : CliGlobalOptions, ICommand +{ + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + await using var client = await AppApiClient.ConnectAsync(console.RegisterCancellationHandler()); + var vaults = await client.ListVaultsAsync(); + + if (vaults.Count == 0) + { + CliOutput.Info(console, this, "No vaults are visible to integrations."); + Environment.ExitCode = CliExitCodes.Success; + return; + } + + foreach (var vault in vaults) + { + var suffix = vault.State == "unlocked" ? $" {vault.MountPath}" : string.Empty; + console.Output.WriteLine($"{vault.Id} {vault.State,-8} {vault.Name}{suffix}"); + } + + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } +} + +/// +/// Asks a running SecureFolderFS instance to show its unlock prompt. +/// +[Command("app unlock", Description = "Ask a running SecureFolderFS instance to prompt for unlocking a vault.")] +public sealed partial class AppUnlockCommand : CliGlobalOptions, ICommand +{ + [CommandParameter(0, Name = "vaultId", Description = "Vault id as reported by 'app vaults'.")] + public required string VaultId { get; set; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + await using var client = await AppApiClient.ConnectAsync(console.RegisterCancellationHandler()); + var status = await client.RequestUnlockAsync(VaultId); + + // The prompt is shown to the user; this command deliberately does not wait for the outcome, + // because the user may take any amount of time or cancel outright. + CliOutput.Info(console, this, status switch + { + "already_pending" => "An unlock prompt for this vault is already open.", + "no_change" => "The vault is already unlocked.", + _ => "SecureFolderFS is prompting for credentials." + }); + + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } +} + +/// +/// Locks a vault in a running SecureFolderFS instance. +/// +[Command("app lock", Description = "Lock a vault in a running SecureFolderFS instance.")] +public sealed partial class AppLockCommand : CliGlobalOptions, ICommand +{ + [CommandParameter(0, Name = "vaultId", Description = "Vault id as reported by 'app vaults'.")] + public required string VaultId { get; set; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + await using var client = await AppApiClient.ConnectAsync(console.RegisterCancellationHandler()); + var status = await client.LockAsync(VaultId); + + CliOutput.Info(console, this, status == "no_change" + ? "The vault is already locked." + : "Lock requested."); + + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs index 474505dbb..1bcecab69 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/PdfStreamServer.cs @@ -1,6 +1,6 @@ using System.Net; using System.Net.Sockets; -using System.Text; +using System.Security.Cryptography; using SecureFolderFS.Shared.ComponentModel; #if ANDROID @@ -16,9 +16,11 @@ internal sealed class PdfStreamServer : IAsyncInitialize, IDisposable private readonly Stream _fileStream; private readonly string _mimeType; private readonly int _port; + private readonly string _accessToken; + private readonly SemaphoreSlim _requestSemaphore; private bool _disposed; - public string BaseAddress => $"http://localhost:{_port}"; + public string BaseAddress => $"http://localhost:{_port}/{_accessToken}"; public PdfStreamServer(Stream fileStream, string mimeType) { @@ -28,6 +30,14 @@ public PdfStreamServer(Stream fileStream, string mimeType) _fileStream = fileStream; _mimeType = mimeType; + // The listener is reachable by every process on the device. Require a + // cryptographically random token in the path so other local apps cannot + // read the decrypted document while the preview is open + _accessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + + // Requests share one seekable stream so serve them one at a time + _requestSemaphore = new SemaphoreSlim(1, 1); + // Automatically find a free port _port = GetAvailablePort(); @@ -50,109 +60,26 @@ async Task BeginListeningAsync() { while (!_disposed && _httpListener.IsListening && await _httpListener.GetContextAsync() is var context) { - var response = context.Response; - var absolutePath = context.Request.Url?.AbsolutePath ?? string.Empty; - + await _requestSemaphore.WaitAsync(cancellationToken); try { - if (absolutePath == "/app_file") - { - response.ContentType = _mimeType; - response.Headers["Accept-Ranges"] = "bytes"; - response.ContentLength64 = _fileStream.Length; - - await _fileStream.CopyToAsync(response.OutputStream, cancellationToken); - if (_fileStream.CanSeek) - _fileStream.Position = 0L; - - response.StatusCode = (int)HttpStatusCode.OK; - response.StatusDescription = "OK"; - } - else if (absolutePath.StartsWith("/pdfjs53")) - { -#if ANDROID - var relativePath = absolutePath.TrimStart('/'); - var contentType = FileTypeHelper.GetMimeType(relativePath); - response.ContentType = contentType; - response.Headers["Accept-Ranges"] = "bytes"; - - await using var assetStream = Android.App.Application.Context.Assets?.Open(relativePath, Access.Random); - if (assetStream is null) - { - response.StatusCode = (int)HttpStatusCode.NotFound; - continue; - } - - // All this double-copying of data is needed for setting the ContentLength tag - - // Copy to temporary MemoryStream - await using var memoryStream = new MemoryStream(); - await assetStream.CopyToAsync(memoryStream, cancellationToken); - await memoryStream.FlushAsync(cancellationToken); - memoryStream.Position = 0L; - - // Set the ContentLength tag - response.ContentLength64 = memoryStream.Length; - - // Copy back to the OutputStream - await memoryStream.CopyToAsync(response.OutputStream, cancellationToken); - await response.OutputStream.FlushAsync(cancellationToken); - response.StatusCode = (int)HttpStatusCode.OK; - response.StatusDescription = "OK"; -#endif - } + await ProcessRequestAsync(context, cancellationToken); } - catch (Exception ex) + catch (Exception) { - var title = "Internal Server Error"; - var message = WebUtility.HtmlEncode(ex.Message); - var stackTrace = WebUtility.HtmlEncode(ex.StackTrace ?? ""); - - var html = $$""" - - - - - {{title}} - - - -

{{title}}

-

{{message}}

-
{{stackTrace}}
- - - """; - - try - { - var buffer = Encoding.UTF8.GetBytes(html); - response.StatusCode = (int)HttpStatusCode.InternalServerError; - response.ContentType = "text/html"; - response.ContentLength64 = buffer.Length; - await response.OutputStream.WriteAsync(buffer, cancellationToken); - } - catch (Exception) { } + TryWriteErrorResponse(context.Response); } finally { - response.Close(); + _requestSemaphore.Release(); + try + { + context.Response.Close(); + } + catch (Exception) + { + // The connection may already be gone + } } } } @@ -163,12 +90,88 @@ async Task BeginListeningAsync() } } + private async Task ProcessRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + var response = context.Response; + var absolutePath = context.Request.Url?.AbsolutePath ?? string.Empty; + + // Reject any request that does not carry the access token + var tokenPrefix = $"/{_accessToken}"; + if (!absolutePath.StartsWith(tokenPrefix, StringComparison.Ordinal)) + { + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + var relativePath = absolutePath[tokenPrefix.Length..]; + if (relativePath == "/app_file") + { + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = _mimeType; + response.ContentLength64 = _fileStream.Length; + + _fileStream.Position = 0L; + await _fileStream.CopyToAsync(response.OutputStream, cancellationToken); + _fileStream.Position = 0L; + } + else if (relativePath.StartsWith("/pdfjs53", StringComparison.Ordinal)) + { +#if ANDROID + var assetPath = relativePath.TrimStart('/'); + var contentType = FileTypeHelper.GetMimeType(assetPath); + + await using var assetStream = Android.App.Application.Context.Assets?.Open(assetPath, Access.Random); + if (assetStream is null) + { + response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + // Buffer the asset first - ContentLength64 must be known before the body is written + await using var memoryStream = new MemoryStream(); + await assetStream.CopyToAsync(memoryStream, cancellationToken); + memoryStream.Position = 0L; + + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = contentType; + response.ContentLength64 = memoryStream.Length; + + await memoryStream.CopyToAsync(response.OutputStream, cancellationToken); + await response.OutputStream.FlushAsync(cancellationToken); +#else + response.StatusCode = (int)HttpStatusCode.NotFound; +#endif + } + else + { + response.StatusCode = (int)HttpStatusCode.NotFound; + } + } + + private static void TryWriteErrorResponse(HttpListenerResponse response) + { + try + { + // Don't leak exception details to other local processes by making it deliberately generic + var buffer = "Internal Server Error"u8.ToArray(); + response.StatusCode = (int)HttpStatusCode.InternalServerError; + response.ContentType = "text/plain"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer); + } + catch (Exception) + { + // Headers may already have been sent + } + } + /// public void Dispose() { _disposed = true; _fileStream.Dispose(); _httpListener.Abort(); + _requestSemaphore.Dispose(); } private static int GetAvailablePort() diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs index ca13f57a7..75592cb3a 100644 --- a/src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs @@ -9,6 +9,11 @@ using SecureFolderFS.UI.ServiceImplementation; using SecureFolderFS.UI.ServiceImplementation.Settings; using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions; +#if APP_PLATFORM_PRESENT +using Microsoft.Extensions.DependencyInjection; +using SecureFolderFS.Sdk.AppPlatform.Services; +using SecureFolderFS.Shared.ComponentModel; +#endif namespace SecureFolderFS.Maui.Extensions { @@ -26,6 +31,12 @@ public static IServiceCollection WithMauiServices(this IServiceCollection servic .Foundation(AddService.AddSingleton) .Foundation(AddService.AddTransient) +#if APP_PLATFORM_PRESENT + .Foundation(AddService.AddSingleton) + .Foundation(AddService.AddSingleton, sp => new SecurePropertyKeyStore(sp.GetRequiredService().SecurePropertyStore, settingsFolder)) + .Foundation(AddService.AddSingleton, sp => new AppPlatformAccountProvider(sp.GetRequiredService())) +#endif + .AddBottomSheet(nameof(ViewOptionsSheet)) ; } diff --git a/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs b/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs index 8352e62dc..2fee405dc 100644 --- a/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs +++ b/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs @@ -40,13 +40,13 @@ public void UpdateFromRectangle(IList allItems, Func + /// Implements swipe-to-select on Android by claiming the gesture at the RecyclerView level. + /// + /// + /// MAUI gesture recognizers cannot drive this feature on Android: a PanGestureRecognizer + /// conflicts with the per-item TapGestureRecognizer, and the gesture is lost mid-way to the + /// RecyclerView's own scroll interception and to SwipeRefreshLayout (RefreshView). An + /// is consulted BEFORE the RecyclerView's own + /// touch handling, so once horizontal intent is detected the gesture can be claimed for + /// selection - blocking scrolling for its duration - while purely vertical gestures are left + /// untouched and scroll the list normally. Taps and long-presses (context menu) never move + /// past the intent threshold and keep working unchanged. + /// + public sealed class SwipeSelectionItemTouchListener : Java.Lang.Object, RecyclerView.IOnItemTouchListener + { + private readonly BrowserControl? _browserControl; + private float _downX; + private float _downY; + private bool _isTracking; + private bool _isSelectionActive; + + public SwipeSelectionItemTouchListener(BrowserControl browserControl) + { + _browserControl = browserControl; + } + + // Activation constructor used when the Android runtime marshals an existing Java peer + // back into managed code. Required boilerplate for Java.Lang.Object subclasses. + public SwipeSelectionItemTouchListener(nint javaReference, JniHandleOwnership transfer) + : base(javaReference, transfer) + { + } + + /// + public bool OnInterceptTouchEvent(RecyclerView rv, MotionEvent e) + { + if (_browserControl is null || !_browserControl.IsSelecting) + return false; + + switch (e.ActionMasked) + { + case MotionEventActions.Down: + { + _downX = e.GetX(); + _downY = e.GetY(); + _isTracking = true; + _isSelectionActive = false; + break; + } + + case MotionEventActions.Move when _isTracking && !_isSelectionActive: + { + var density = GetDensity(rv); + var totalX = (e.GetX() - _downX) / density; + var totalY = (e.GetY() - _downY) / density; + var absX = Math.Abs(totalX); + var absY = Math.Abs(totalY); + + // Vertical intent - stop tracking and let the RecyclerView scroll + if (absY > absX && absY > BrowserControl.SWIPE_SELECTION_MIN_HORIZONTAL_THRESHOLD) + { + _isTracking = false; + return false; + } + + if (absX < BrowserControl.SWIPE_SELECTION_MIN_HORIZONTAL_THRESHOLD || absY > absX) + return false; + + // Horizontal intent confirmed - resolve the item under the initial touch + var child = rv.FindChildViewUnder(_downX, _downY); + var originIndex = child is null ? RecyclerView.NoPosition : rv.GetChildAdapterPosition(child); + if (child is null || originIndex == RecyclerView.NoPosition) + { + _isTracking = false; + return false; + } + + if (!_browserControl.TryBeginPlatformSwipeSelection(originIndex, child.Height / density, totalX, totalY)) + { + _isTracking = false; + return false; + } + + // Claim the gesture: subsequent events arrive in OnTouchEvent, and parents + // (e.g. SwipeRefreshLayout backing RefreshView) may no longer intercept it + _isSelectionActive = true; + rv.Parent?.RequestDisallowInterceptTouchEvent(true); + return true; + } + + case MotionEventActions.Up: + case MotionEventActions.Cancel: + { + _isTracking = false; + break; + } + } + + return false; + } + + /// + public void OnTouchEvent(RecyclerView rv, MotionEvent e) + { + if (!_isSelectionActive || _browserControl is null) + return; + + switch (e.ActionMasked) + { + case MotionEventActions.Move: + { + var density = GetDensity(rv); + _browserControl.UpdatePlatformSwipeSelection( + (e.GetX() - _downX) / density, + (e.GetY() - _downY) / density); + break; + } + + case MotionEventActions.Up: + case MotionEventActions.Cancel: + { + _isSelectionActive = false; + _isTracking = false; + rv.Parent?.RequestDisallowInterceptTouchEvent(false); + _browserControl.EndPlatformSwipeSelection(); + break; + } + } + } + + /// + public void OnRequestDisallowInterceptTouchEvent(bool disallowIntercept) + { + } + + private static float GetDensity(RecyclerView rv) + { + var density = rv.Resources?.DisplayMetrics?.Density ?? 1f; + return density > 0f ? density : 1f; + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidShareService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidShareService.cs index 399531527..622598e2f 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidShareService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidShareService.cs @@ -46,26 +46,5 @@ public async Task ShareFileAsync(IFile file) Application.Context.StartActivity(chooserIntent); await Task.CompletedTask; } - - /// - public Task OpenFileWithAsync(IFile file) - { - // Build the content URI for this file - var contentUri = ShareContentProvider.RegisterFileAndBuildUri(Application.Context, file); - - // Determine MIME type - var mimeType = FileTypeHelper.GetMimeType(file.Name); - - // Create view intent - var intent = new Intent(Intent.ActionView); - intent.SetDataAndType(contentUri, mimeType); - intent.AddFlags(ActivityFlags.GrantReadUriPermission); - - var chooserIntent = Intent.CreateChooser(intent, file.Name); - chooserIntent?.AddFlags(ActivityFlags.NewTask); - - Application.Context.StartActivity(chooserIntent); - return Task.CompletedTask; - } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs index d1e381cd1..49c0822cd 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs @@ -1,6 +1,7 @@ using OwlCore.Storage; using SecureFolderFS.Maui.Platforms.Android.Storage; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.Helpers; namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation { @@ -50,5 +51,39 @@ public Task GetAvailableFreeSpaceAsync(IFolder storageRoot, CancellationTo #endif } + /// + public Task OpenFileWithAsync(IFile file, CancellationToken cancellationToken = default) + { + // Build the content URI for this file + var contentUri = ShareContentProvider.RegisterFileAndBuildUri(global::Android.App.Application.Context, file); + + // Determine MIME type + var mimeType = FileTypeHelper.GetMimeType(file.Name); + + // Create view intent + var intent = new global::Android.Content.Intent(global::Android.Content.Intent.ActionView); + intent.SetDataAndType(contentUri, mimeType); + intent.AddFlags(global::Android.Content.ActivityFlags.GrantReadUriPermission); + + var chooserIntent = global::Android.Content.Intent.CreateChooser(intent, file.Name); + chooserIntent?.AddFlags(global::Android.Content.ActivityFlags.NewTask); + + global::Android.App.Application.Context.StartActivity(chooserIntent); + return Task.CompletedTask; + } + + /// + public Task IsAutoStartEnabledAsync(CancellationToken cancellationToken = default) + { + // Auto start is not supported on mobile platforms + return Task.FromResult(false); + } + + /// + public Task TrySetAutoStartAsync(bool isEnabled, CancellationToken cancellationToken = default) + { + // Auto start is not supported on mobile platforms + return Task.FromResult(false); + } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs index 85337812d..ffec20453 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs @@ -50,7 +50,7 @@ protected override async IAsyncEnumerable GetLoginAsync Constants.Vault.Authentication.AUTH_ANDROID_BIOMETRIC => new AndroidBiometricLoginViewModel(vaultFolder, vaultId), // App Platform - Constants.Vault.Authentication.AUTH_APP_PLATFORM => new AppPlatformLoginViewModel(), + Constants.Vault.Authentication.AUTH_APP_PLATFORM => new AppPlatformLoginViewModel(vaultFolder), _ => throw new NotSupportedException($"The authentication method '{item}' is not supported by the platform.") }; diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Helpers/IOSLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Helpers/IOSLifecycleHelper.cs index 34faece57..11cd6b941 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Helpers/IOSLifecycleHelper.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Helpers/IOSLifecycleHelper.cs @@ -21,7 +21,7 @@ internal sealed class IOSLifecycleHelper : BaseLifecycleHelper public override Task InitAsync(CancellationToken cancellationToken = default) { // Initialize settings - var settingsFolderPath = Path.Combine(AppDirectory, Constants.FileNames.SETTINGS_FOLDER_NAME); + var settingsFolderPath = Path.Combine(AppDirectory, Constants.FileNames.Settings.SETTINGS_FOLDER_NAME); var settingsFolder = new SystemFolder(Directory.CreateDirectory(settingsFolderPath)); ConfigureServices(settingsFolder); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs index 9b2cc040d..d499c9d71 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs @@ -32,12 +32,6 @@ public async Task ShareFileAsync(IFile file) await PresentActivityControllerAsync(activityController); } - /// - public Task OpenFileWithAsync(IFile file) - { - return Task.FromException(new NotSupportedException("Opening files with associated applications is not supported on iOS. Use ShareFileAsync to share the file instead.")); - } - private static Task PresentActivityControllerAsync(UIActivityViewController activityController) { var tcs = new TaskCompletionSource(); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs index 6ac8c405e..d3ad39743 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs @@ -37,5 +37,25 @@ public async Task GetAvailableFreeSpaceAsync(IFolder storageRoot, Cancella throw new PlatformNotSupportedException("Only implemented on iOS."); #endif } + + /// + public Task OpenFileWithAsync(IFile file, CancellationToken cancellationToken = default) + { + return Task.FromException(new NotSupportedException("Opening files with associated applications is not supported on iOS. Use IShareService.ShareFileAsync instead.")); + } + + /// + public Task IsAutoStartEnabledAsync(CancellationToken cancellationToken = default) + { + // Auto start is not supported on mobile platforms + return Task.FromResult(false); + } + + /// + public Task TrySetAutoStartAsync(bool isEnabled, CancellationToken cancellationToken = default) + { + // Auto start is not supported on mobile platforms + return Task.FromResult(false); + } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs index b7a843445..f988b86c5 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs @@ -63,7 +63,7 @@ Constants.Vault.Authentication.AUTH_APPLE_BIOMETRIC when AreBiometricsAvailable( }), // App Platform - Constants.Vault.Authentication.AUTH_APP_PLATFORM => new AppPlatformLoginViewModel(), + Constants.Vault.Authentication.AUTH_APP_PLATFORM => new AppPlatformLoginViewModel(vaultFolder), _ => throw new NotSupportedException($"The authentication method '{item}' is not supported by the platform.") }; diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml b/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml index 14e955315..ae758841d 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Popups/PropertiesPopup.xaml @@ -104,6 +104,13 @@ IsVisible="{Binding ViewModel.SizeText, Mode=OneWay, Converter={StaticResource NullToBoolConverter}}" Subtitle="{Binding ViewModel.SizeText, Mode=OneWay}" /> + + + diff --git a/src/Platforms/SecureFolderFS.Maui/Resources/Styles/ColorStyles.xaml b/src/Platforms/SecureFolderFS.Maui/Resources/Styles/ColorStyles.xaml index 7cd2be35f..c611fe93d 100644 --- a/src/Platforms/SecureFolderFS.Maui/Resources/Styles/ColorStyles.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Resources/Styles/ColorStyles.xaml @@ -44,6 +44,20 @@ #2E3236 #464A4F + + #D3E5F9 + #24384C + #D5EEDB + #1F3A28 + #FADEDB + #442220 + + + #2E9E4F + #30C24F + #C42B1C + #FF453A + #CECECE #737373 @@ -79,6 +93,11 @@ + + + + + diff --git a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs index b935d6fa8..8123fdd3b 100644 --- a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/BaseMauiMediaService.cs @@ -101,6 +101,12 @@ public virtual Task TrySetFolderIconAsync(IModifiableFolder folder, Stream return Task.FromResult(false); } + /// + public virtual Task GetSystemIconAsync(string itemName, bool isFolder, uint size = 64u, CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } + /// public abstract Task GenerateThumbnailAsync(IFile file, TypeHint typeHint = default, CancellationToken cancellationToken = default); } diff --git a/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/MauiOidcProvider.cs b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/MauiOidcProvider.cs new file mode 100644 index 000000000..83cc7a7dc --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/ServiceImplementation/MauiOidcProvider.cs @@ -0,0 +1,16 @@ +#if APP_PLATFORM_PRESENT +using SecureFolderFS.Sdk.AppPlatform.Helpers; + +namespace SecureFolderFS.Maui.ServiceImplementation +{ + /// + internal sealed class MauiOidcProvider : BrowserAuthProvider + { + /// + protected override async Task OpenSystemBrowserAsync(string authUrl, CancellationToken ct) + { + await Browser.Default.OpenAsync(authUrl, BrowserLaunchMode.SystemPreferred); + } + } +} +#endif diff --git a/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml b/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml index 73139cd5a..ea1e8da39 100644 --- a/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Sheets/ViewOptionsSheet.xaml @@ -36,6 +36,14 @@ SelectedItem="{Binding ViewModel.CurrentSortOption, Mode=TwoWay}" /> + + + + + IOSBiometricsTemplate, #endif + AppPlatformLoginViewModel => AppPlatformTemplate, PersistedAuthenticationViewModel => PersistedAuthenticationTemplate, + RecoveryRequirementViewModel => RecoveryRequirementTemplate, ErrorViewModel => ErrorTemplate, UnsupportedViewModel => UnsupportedTemplate, _ => null diff --git a/src/Platforms/SecureFolderFS.Maui/TemplateSelectors/RegistrationTemplateSelector.cs b/src/Platforms/SecureFolderFS.Maui/TemplateSelectors/RegistrationTemplateSelector.cs index 866801718..f2a5073c2 100644 --- a/src/Platforms/SecureFolderFS.Maui/TemplateSelectors/RegistrationTemplateSelector.cs +++ b/src/Platforms/SecureFolderFS.Maui/TemplateSelectors/RegistrationTemplateSelector.cs @@ -15,6 +15,8 @@ internal sealed class RegistrationTemplateSelector : DataTemplateSelector public DataTemplate? KeyFileTemplate { get; set; } + public DataTemplate? AppPlatformTemplate { get; set; } + #if ANDROID public DataTemplate? AndroidBiometricsTemplate { get; set; } #elif IOS @@ -39,6 +41,7 @@ internal sealed class RegistrationTemplateSelector : DataTemplateSelector #elif IOS IOSBiometricCreationViewModel => IOSBiometricsTemplate, #endif + AppPlatformCreationViewModel => AppPlatformTemplate, _ => null }; } diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs index ee6459f44..7a557101d 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.DragDrop.xaml.cs @@ -113,8 +113,8 @@ private async void DropGestureRecognizer_Drop(object? sender, DropEventArgs e) if (draggedItem == folderViewModel) return; - // Disallow dropping a folder into its own subfolder - if (folderViewModel.Inner.Id.Contains(draggedItem.Inner.Id, StringComparison.InvariantCultureIgnoreCase)) + // Disallow dropping a folder into itself or its own subfolder + if (BrowserItemViewModel.IsAncestorOrSelf(folderViewModel.Inner.Id, draggedItem.Inner.Id)) return; await MoveItemToFolderAsync(draggedItem, folderViewModel); @@ -160,24 +160,18 @@ private async void CollectionDropGestureRecognizer_Drop(object? sender, DropEven if (draggedItem.ParentFolder == currentFolder) return; - // Disallow dropping a folder into its own subfolder - if (currentFolder.Inner.Id.Contains(draggedItem.Inner.Id, StringComparison.InvariantCultureIgnoreCase)) + // Disallow dropping a folder into itself or its own subfolder + if (BrowserItemViewModel.IsAncestorOrSelf(currentFolder.Inner.Id, draggedItem.Inner.Id)) return; await MoveItemToFolderAsync(draggedItem, currentFolder); return; } - // Handle external files dropped from system apps (e.g., Files app) - // Get the current folder from the ItemsSource binding - if (ItemsSource is not { Count: >= 0 } items) - return; - - // Try to get the BrowserViewModel from the first item, or from the binding context - var firstItem = items.FirstOrDefault(); - var targetBrowserViewModel = firstItem?.BrowserViewModel; - var targetFolder = targetBrowserViewModel?.CurrentFolder; - + // Handle external files dropped from system apps (e.g., Files app). + // The target folder comes from the bound view model so that drops + // into an empty folder (no items to read the context from) also work + var targetFolder = ViewModel?.CurrentFolder ?? ItemsSource?.FirstOrDefault()?.BrowserViewModel.CurrentFolder; if (targetFolder is null) return; @@ -237,9 +231,9 @@ await transferViewModel.TransferAsync([ itemToMove ], async (item, reporter, tok { // Cancellation, nothing to report } - catch (Exception) + catch (Exception ex) { - await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + await transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } finally { @@ -309,9 +303,9 @@ private static async Task CopyExternalFilesToFolderAsync(DropEventArgs dropEvent if (string.IsNullOrEmpty(suggestedExtension) && !string.IsNullOrEmpty(extension)) actualName = suggestedName + extension; - itemsToProcess.Add((actualName, async _ => + itemsToProcess.Add((actualName, async ct => { - var dataTcs = new TaskCompletionSource(); + var dataTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var utType = UTType.CreateFromIdentifier(capturedTypeId); if (utType is null) return null; @@ -321,7 +315,8 @@ private static async Task CopyExternalFilesToFolderAsync(DropEventArgs dropEvent dataTcs.TrySetResult(data); }); - var data = await dataTcs.Task; + // Guard against providers that never invoke the completion callback + var data = await dataTcs.Task.WaitAsync(TimeSpan.FromSeconds(60), ct); return data?.AsStream(); }, false)); @@ -331,7 +326,7 @@ private static async Task CopyExternalFilesToFolderAsync(DropEventArgs dropEvent // Second, try to load as a file URL (works for Files app) if (itemProvider.HasItemConformingTo(UTTypes.Item.Identifier)) { - var tcs = new TaskCompletionSource<(NSUrl? Url, bool IsFolder)>(); + var tcs = new TaskCompletionSource<(NSUrl? Url, bool IsFolder)>(TaskCreationOptions.RunContinuationsAsynchronously); itemProvider.LoadItem(UTTypes.Item.Identifier, null, (item, _) => { if (item is NSUrl { Path: not null } itemUrl) @@ -346,7 +341,7 @@ private static async Task CopyExternalFilesToFolderAsync(DropEventArgs dropEvent } }); - var (url, isFolder) = await tcs.Task; + var (url, isFolder) = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(60)); if (url is not null) { // Get the actual filename from the URL path - this should include the correct extension @@ -407,15 +402,16 @@ private static async Task CopyExternalFilesToFolderAsync(DropEventArgs dropEvent if (itemProvider.HasItemConformingTo(UTTypes.Data.Identifier)) { var capturedProvider = itemProvider; - itemsToProcess.Add((suggestedName, async _ => + itemsToProcess.Add((suggestedName, async ct => { - var dataTcs = new TaskCompletionSource(); + var dataTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); capturedProvider.LoadDataRepresentation(UTTypes.Data, (data, _) => { dataTcs.TrySetResult(data); }); - var data = await dataTcs.Task; + // Guard against providers that never invoke the completion callback + var data = await dataTcs.Task.WaitAsync(TimeSpan.FromSeconds(60), ct); return data?.AsStream(); }, false)); } @@ -433,12 +429,14 @@ private static async Task CopyExternalFilesToFolderAsync(DropEventArgs dropEvent if (destinationViewModel.Items.IsEmpty()) await destinationViewModel.ListContentsAsync(cts.Token); + var existingNames = new HashSet(destinationViewModel.Items.Select(x => x.Inner.Name), StringComparer.OrdinalIgnoreCase); await transferViewModel.TransferAsync(itemsToProcess, async (item, reporter, token) => { token.ThrowIfCancellationRequested(); // Get available name to avoid collision - var availableName = CollisionHelpers.GetAvailableName(item.Name, destinationViewModel.Items.Select(x => x.Inner.Name)); + var availableName = CollisionHelpers.GetAvailableName(item.Name, existingNames); + existingNames.Add(availableName); if (item.IsFolder) { @@ -470,9 +468,9 @@ await transferViewModel.TransferAsync(itemsToProcess, async (item, reporter, tok { // Cancellation, nothing to report } - catch (Exception) + catch (Exception ex) { - await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + await transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } finally { diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs index 43cf5900d..853695e93 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Rendering.xaml.cs @@ -12,6 +12,11 @@ public partial class BrowserControl private readonly ISettingsService _settingsService; private int _skipCollectionViewLayoutPass; private CollectionView? _collectionView; + private BrowserViewType? _appliedViewType; +#if ANDROID + private Platforms.Android.Helpers.SwipeSelectionItemTouchListener? _swipeSelectionTouchListener; + private AndroidX.RecyclerView.Widget.RecyclerView? _swipeSelectionRecyclerView; +#endif /// /// Determines if the CollectionView can be reloaded. @@ -22,6 +27,16 @@ public bool CanReloadCollection() return _skipCollectionViewLayoutPass == 0; } + /// + /// Determines whether a reload would actually recreate the CollectionView, + /// i.e. whether the applied layout differs from the requested . + /// + /// Returns true if a reload is needed; otherwise, false. + public bool NeedsCollectionReload() + { + return _appliedViewType != ViewType; + } + /// /// Forces complete recreation of the CollectionView to work around MAUI layout glitches /// when changing ItemsLayout dynamically. @@ -37,6 +52,10 @@ public async Task ReloadCollectionViewAsync() if (_collectionView is null) return; + // Recreating the native list is expensive - skip when the layout did not change + if (_appliedViewType == ViewType) + return; + // Find the parent container var container = CollectionViewContainer; if (container is null) @@ -56,7 +75,8 @@ public async Task ReloadCollectionViewAsync() var newCollectionView = new CollectionView() { ItemsLayout = ViewTypeToItemsLayoutConverter.ConvertLayout(ViewType), - ItemSizingStrategy = ItemSizingStrategy.MeasureAllItems, + // Items are uniformly sized within every layout, so measuring one is enough + ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem, ItemTemplate = itemTemplate, Margin = ViewType is BrowserViewType.SmallGridView or BrowserViewType.MediumGridView or BrowserViewType.LargeGridView ? new(16d) @@ -85,12 +105,15 @@ public async Task ReloadCollectionViewAsync() // Wire up the events newCollectionView.Loaded += ItemsCollectionView_Loaded; newCollectionView.SizeChanged += ItemsCollectionView_SizeChanged; + newCollectionView.Scrolled += ItemsCollectionView_Scrolled; // Add the new CollectionView to the container container.Children.Add(newCollectionView); // Update our reference _collectionView = newCollectionView; + _appliedViewType = ViewType; + _currentScrollY = 0d; // Fade in await _collectionView.FadeToAsync(1, 100); @@ -153,8 +176,12 @@ private void ItemsCollectionView_Loaded(object? sender, EventArgs e) { _collectionView = sender as CollectionView; - // Set initial ItemsLayout since we removed the binding from XAML - _collectionView?.ItemsLayout = ViewTypeToItemsLayoutConverter.ConvertLayout(ViewType); + // Set initial ItemsLayout since we removed the binding from XAML. + // Recreated collection views already arrive with the correct layout applied + if (_appliedViewType != ViewType) + _collectionView?.ItemsLayout = ViewTypeToItemsLayoutConverter.ConvertLayout(ViewType); + + _appliedViewType = ViewType; #if ANDROID // On Android, keep SelectionMode as None to prevent CollectionView re-layout @@ -167,6 +194,60 @@ private void ItemsCollectionView_Loaded(object? sender, EventArgs e) new Binding(nameof(IsSelecting), mode: BindingMode.OneWay, source: this, converter: GetConverter(nameof(BoolSelectionModeConverter)))); #endif + + AttachPlatformSwipeSelection(); + } + + /// + /// Attaches the native swipe-selection handler to the CollectionView's backing list. + /// On Android this hooks an item-touch listener into the RecyclerView; MAUI gesture + /// recognizers cannot claim the gesture there (see SwipeSelectionItemTouchListener). + /// On other platforms this is a no-op - selection uses per-item pan gestures. + /// + private void AttachPlatformSwipeSelection() + { +#if ANDROID + if (_collectionView?.Handler?.PlatformView is not AndroidX.RecyclerView.Widget.RecyclerView recyclerView) + return; + + if (ReferenceEquals(_swipeSelectionRecyclerView, recyclerView)) + return; + + // Detach from the previous RecyclerView - a recreated CollectionView gets a new one + if (_swipeSelectionRecyclerView is not null && _swipeSelectionTouchListener is not null) + { + try + { + _swipeSelectionRecyclerView.RemoveOnItemTouchListener(_swipeSelectionTouchListener); + } + catch (Exception) + { + // The old RecyclerView may already be disposed along with its handler + } + } + + _swipeSelectionTouchListener ??= new(this); + recyclerView.AddOnItemTouchListener(_swipeSelectionTouchListener); + _swipeSelectionRecyclerView = recyclerView; +#endif + } + + /// + /// Enables or disables pull-to-refresh based on the selection mode. + /// + /// + /// On Android, SwipeRefreshLayout deliberately ignores RequestDisallowInterceptTouchEvent + /// (legacy AndroidX behavior), so dragging the selection rectangle downward would still + /// trigger a refresh and cancel the gesture. The refresh gesture is therefore turned off + /// entirely at the native level while selecting. Other platforms are unaffected - their + /// selection gesture never reaches the refresh control. + /// + private void UpdatePullToRefreshState() + { +#if ANDROID + if (RootRefreshView.Handler?.PlatformView is AndroidX.SwipeRefreshLayout.Widget.SwipeRefreshLayout swipeRefreshLayout) + swipeRefreshLayout.Enabled = !IsSelecting; +#endif } private void ItemsCollectionView_SizeChanged(object? sender, EventArgs e) diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Selection.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Selection.xaml.cs index 7eb1a2745..01c4b7a76 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Selection.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.Selection.xaml.cs @@ -5,11 +5,14 @@ namespace SecureFolderFS.Maui.UserControls.Browser { public partial class BrowserControl { - private const double SWIPE_SELECTION_MIN_HORIZONTAL_THRESHOLD = 10d; + internal const double SWIPE_SELECTION_MIN_HORIZONTAL_THRESHOLD = 10d; + private readonly SwipeSelectionManager _swipeSelectionManager = new(); private Point _swipeOriginCenterPoint; private Point? _swipeStartPan; - private ScrollView? _scrollView; + private double _swipeOriginItemHeight; + private double _currentScrollY; + private Dictionary? _swipeIndexMap; private async void TapGestureRecognizer_Tapped(object? sender, TappedEventArgs e) { @@ -24,11 +27,24 @@ private async void TapGestureRecognizer_Tapped(object? sender, TappedEventArgs e else { view.IsEnabled = false; - if (itemViewModel is not FolderViewModel) + var skipReload = itemViewModel is not FolderViewModel; + if (skipReload) _skipCollectionViewLayoutPass++; - await itemViewModel.OpenCommand.ExecuteAsync(null); - view.IsEnabled = true; + try + { + await itemViewModel.OpenCommand.ExecuteAsync(null); + } + catch (Exception) + { + // The open failed, so no navigation will consume the skipped layout pass + if (skipReload && _skipCollectionViewLayoutPass > 0) + _skipCollectionViewLayoutPass--; + } + finally + { + view.IsEnabled = true; + } } } @@ -62,10 +78,10 @@ internal void ItemContainer_PanUpdated(object? sender, PanUpdatedEventArgs e) return; _swipeSelectionManager.Begin(originItem); - BeginSelectionRectangle(originView, originItem, e.TotalX, e.TotalY); + BeginSelectionRectangle(originItem, originView.Height, e.TotalX, e.TotalY); } - UpdateSelectionRectangle(originView, e.TotalX, e.TotalY); + UpdateSelectionRectangle(e.TotalX, e.TotalY); break; case GestureStatus.Completed: @@ -76,15 +92,66 @@ internal void ItemContainer_PanUpdated(object? sender, PanUpdatedEventArgs e) } } - private void BeginSelectionRectangle(View originView, BrowserItemViewModel originItem, double totalX, double totalY) + /// + /// Begins a swipe selection driven by platform (native) touch events. Used on Android, + /// where the gesture is claimed at the RecyclerView level instead of via MAUI gestures. + /// + /// The index of the item the gesture started on. + /// The height of the origin item, in device-independent units. + /// The horizontal pan total at the time selection was claimed. + /// The vertical pan total at the time selection was claimed. + /// True when the selection was started; otherwise false. + internal bool TryBeginPlatformSwipeSelection(int originIndex, double originItemHeight, double totalX, double totalY) + { + if (!IsSelecting || ItemsSource is null) + return false; + + if (originIndex < 0 || originIndex >= ItemsSource.Count) + return false; + + var originItem = ItemsSource[originIndex]; + _swipeSelectionManager.Begin(originItem); + BeginSelectionRectangle(originItem, originItemHeight, totalX, totalY); + + return true; + } + + /// + /// Updates a swipe selection started with . + /// + internal void UpdatePlatformSwipeSelection(double totalX, double totalY) + { + if (!_swipeSelectionManager.IsActive) + return; + + UpdateSelectionRectangle(totalX, totalY); + } + + /// + /// Ends a swipe selection started with . + /// + internal void EndPlatformSwipeSelection() + { + _swipeSelectionManager.End(); + EndSelectionRectangle(); + } + + private void BeginSelectionRectangle(BrowserItemViewModel originItem, double originItemHeight, double totalX, double totalY) { if (_collectionView is null || ItemsSource is null) return; - GetItemLayout(originView, out var columns, out var itemWidth, out var itemHeight, + _swipeOriginItemHeight = originItemHeight; + GetItemLayout(out var columns, out var itemWidth, out var itemHeight, out var hSpacing, out var vSpacing, out var offsetX, out var offsetY); - var originIndex = ItemsSource.IndexOf(originItem); + // Snapshot item positions once per gesture - looking indices up per item on + // every pan update would be quadratic in the number of items + _swipeIndexMap = new Dictionary(ItemsSource.Count); + for (var i = 0; i < ItemsSource.Count; i++) + _swipeIndexMap[ItemsSource[i]] = i; + + var originIndex = _swipeIndexMap.GetValueOrDefault(originItem, 0); var originCol = originIndex % columns; var originRow = originIndex / columns; @@ -101,12 +168,12 @@ private void BeginSelectionRectangle(View originView, BrowserItemViewModel origi SelectionRectangleCanvas.IsVisible = true; } - private void UpdateSelectionRectangle(View originView, double totalX, double totalY) + private void UpdateSelectionRectangle(double totalX, double totalY) { if (_collectionView is null || ItemsSource is null || _swipeStartPan is null) return; - GetItemLayout(originView, out var columns, out var itemWidth, out var itemHeight, + GetItemLayout(out var columns, out var itemWidth, out var itemHeight, out var hSpacing, out var vSpacing, out var offsetX, out var offsetY); var deltaX = totalX - _swipeStartPan.Value.X; @@ -125,7 +192,7 @@ private void UpdateSelectionRectangle(View originView, double totalX, double tot PositionSelectionRectangle(hitRectCanvas); // Get current scroll offset again (it might have changed during the gesture) - double scrollY = GetCurrentScrollY(); + var scrollY = GetCurrentScrollY(); // Transform hit rectangle into CONTENT coordinates by adding scroll offset var hitRectContent = new Rect( @@ -136,7 +203,9 @@ private void UpdateSelectionRectangle(View originView, double totalX, double tot _swipeSelectionManager.UpdateFromRectangle(ItemsSource, item => { - var index = ItemsSource.IndexOf(item); + if (_swipeIndexMap is null || !_swipeIndexMap.TryGetValue(item, out var index)) + return false; + var col = index % columns; var row = index / columns; @@ -155,6 +224,7 @@ private void EndSelectionRectangle() { SelectionRectangleCanvas.IsVisible = false; _swipeStartPan = null; + _swipeIndexMap = null; } private void PositionSelectionRectangle(Rect rect) @@ -164,7 +234,7 @@ private void PositionSelectionRectangle(Rect rect) SelectionRectangleView.HeightRequest = rect.Height; } - private void GetItemLayout(View originView, out int columns, out double itemWidth, out double itemHeight, + private void GetItemLayout(out int columns, out double itemWidth, out double itemHeight, out double hSpacing, out double vSpacing, out double contentOffsetX, out double contentOffsetY) { hSpacing = 0; @@ -183,37 +253,27 @@ private void GetItemLayout(View originView, out int columns, out double itemWidt var availableWidth = _collectionView.Width - _collectionView.Margin.Left - _collectionView.Margin.Right; itemWidth = (availableWidth - hSpacing * (columns - 1)) / columns; - itemHeight = columns == 1 ? originView.Height : itemWidth; + itemHeight = columns == 1 ? _swipeOriginItemHeight : itemWidth; } - /// Retrieves the current vertical scroll offset of the CollectionView. + /// Retrieves the current vertical scroll offset of the CollectionView, in device-independent units. + /// + /// The offset is tracked via the Scrolled event - the CollectionView is backed by a native + /// list (UICollectionView/RecyclerView), so there is no MAUI ScrollView in its visual tree to query. + /// private double GetCurrentScrollY() { - if (_collectionView == null) - return 0; - - // Lazy‑load the internal ScrollView - if (_scrollView == null) - { - _scrollView = GetInternalScrollView(_collectionView); - } - - return _scrollView?.ScrollY ?? 0; + return _currentScrollY; } - /// Finds the internal ScrollView of a CollectionView via the visual tree. - private static ScrollView? GetInternalScrollView(CollectionView collectionView) + private void ItemsCollectionView_Scrolled(object? sender, ItemsViewScrolledEventArgs e) { - if (collectionView is not IVisualTreeElement vte) - return null; - - // Search the first‑level children – in practice the ScrollView is a direct child. - foreach (var child in vte.GetVisualChildren()) - { - if (child is ScrollView sv) - return sv; - } - return null; +#if ANDROID + // On Android the CollectionView reports scroll offsets in pixels + _currentScrollY = e.VerticalOffset / DeviceDisplay.MainDisplayInfo.Density; +#else + _currentScrollY = e.VerticalOffset; +#endif } private void RegisterItemContainerPanGesture(object? sender) @@ -236,8 +296,10 @@ private void UpdateItemContainerPanGesture( #if ANDROID View _) { - // On Android, PanGestureRecognizer conflicts with TapGestureRecognizer, - // preventing tap-to-select from working. Skip swipe-selection on Android. + // On Android, MAUI's PanGestureRecognizer conflicts with the TapGestureRecognizer and + // loses the gesture to RecyclerView scrolling and SwipeRefreshLayout interception. + // Swipe-selection is instead implemented natively at the RecyclerView level + // (see SwipeSelectionItemTouchListener, attached in BrowserControl.Rendering) } #else View view) diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml index c9f7e536f..a04c71045 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml @@ -298,9 +298,12 @@ + @@ -320,7 +323,7 @@ - + @@ -338,7 +341,7 @@ - + diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml.cs index 30e13538f..4499cef15 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml.cs @@ -1,7 +1,9 @@ using System.Windows.Input; +using CommunityToolkit.Mvvm.Input; using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser; +using SecureFolderFS.Sdk.ViewModels.Views.Vault; using SecureFolderFS.Shared; namespace SecureFolderFS.Maui.UserControls.Browser @@ -11,19 +13,38 @@ public partial class BrowserControl : ContentView public BrowserControl() { _thumbnailSemaphore = new SemaphoreSlim(4, 4); + _thumbnailCts = new CancellationTokenSource(); _settingsService = DI.Service(); InitializeComponent(); } - private void RefreshView_Refreshing(object? sender, EventArgs e) + private async void RefreshView_Refreshing(object? sender, EventArgs e) { if (sender is not RefreshView refreshView) return; - RefreshCommand?.Execute(null); - refreshView.IsRefreshing = false; + try + { + // Keep the spinner visible until the refresh actually completes + if (RefreshCommand is IAsyncRelayCommand asyncRefreshCommand) + await asyncRefreshCommand.ExecuteAsync(null); + else + RefreshCommand?.Execute(null); + } + finally + { + refreshView.IsRefreshing = false; + } } + public BrowserViewModel? ViewModel + { + get => (BrowserViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + public static readonly BindableProperty ViewModelProperty = + BindableProperty.Create(nameof(ViewModel), typeof(BrowserViewModel), typeof(BrowserControl), defaultValue: null); + public bool IsReadOnly { get => (bool)GetValue(IsReadOnlyProperty); @@ -49,8 +70,11 @@ public bool IsSelecting BindableProperty.Create(nameof(IsSelecting), typeof(bool), typeof(BrowserControl), defaultValue: false, propertyChanged: static (bindable, _, _) => { - if (bindable is BrowserControl control) - control.UpdateAllItemContainerPanGestures(); + if (bindable is not BrowserControl control) + return; + + control.UpdateAllItemContainerPanGestures(); + control.UpdatePullToRefreshState(); }); public ICommand? RefreshCommand diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml index 2fd8d2b34..aaa831178 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml @@ -1,9 +1,8 @@ - - - - - + + + + + + - - - + StrokeThickness="0"> + + + + + + + + + + - - - - - + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + internal sealed class FileIconConverter : IValueConverter { + // The platform image loader disposes of the stream it is handed after decoding and can + // request the image again later (recycled cells, re-layouts). Snapshot the bytes once + // per image instance and serve a fresh stream per request, so a re-bind never hits a + // stream that has already been consumed and disposed of + private static readonly ConditionalWeakTable ImageDataSnapshots = new(); + /// public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { @@ -39,29 +44,43 @@ internal sealed class FileIconConverter : IValueConverter private static ImageSource FromImage(IImage image) { - switch (image) + if (image is not IImageStream) + return ImageSource.FromFile(GetDefaultFileIcon()); + + var data = ImageDataSnapshots.GetValue(image, static key => SnapshotBytes(((IImageStream)key).Inner)); + if (data.Length == 0) + return ImageSource.FromFile(GetDefaultFileIcon()); + + return new StreamImageSource + { + Stream = _ => Task.FromResult(new MemoryStream(data, writable: false)) + }; + } + + private static byte[] SnapshotBytes(Stream stream) + { + try + { + // MemoryStream.ToArray is valid even after the stream has been closed + // by a previous image decoding + if (stream is MemoryStream memoryStream) + return memoryStream.ToArray(); + + if (!stream.CanRead || !stream.CanSeek) + return []; + + var savedPosition = stream.Position; + stream.Position = 0L; + + var data = new byte[stream.Length]; + stream.ReadExactly(data); + stream.TrySetPositionOrAdvance(savedPosition); + + return data; + } + catch (Exception) { - case StreamImageModel { Inner.CanRead: true } sim: - { - sim.Inner.TrySetPositionOrAdvance(0L); - return new StreamImageSource - { - Stream = _ => - { - sim.Inner.TrySetPositionOrAdvance(0L); - return Task.FromResult(sim.Inner); - } - }; - } - - case ImageStreamSource { Inner.CanRead: true } iss: - { - iss.Inner.TrySetPositionOrAdvance(0L); - return iss.Source; - } - - default: - return ImageSource.FromFile(GetDefaultFileIcon()); + return []; } } diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/DeviceLink/DeviceLinkCredentialsPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Modals/DeviceLink/DeviceLinkCredentialsPage.xaml index 335451b62..c11237553 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/DeviceLink/DeviceLinkCredentialsPage.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/DeviceLink/DeviceLinkCredentialsPage.xaml @@ -128,7 +128,7 @@ FontSize="11" HorizontalOptions="Center" Opacity="0.8" - Text="{l:ResourceString Rid=DeviceLinkSourceThisDevice}" + Text="{l:ResourceString Rid=ThisDevice}" TextColor="{AppThemeBinding Light={StaticResource PrimaryLightColor}, Dark={StaticResource PrimaryDarkColor}}" TextTransform="Uppercase" /> diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Settings/SettingsPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Settings/SettingsPage.xaml index 52c70394d..c6cf50e2b 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Settings/SettingsPage.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Settings/SettingsPage.xaml @@ -11,6 +11,7 @@ xmlns:ucc="clr-namespace:SecureFolderFS.Maui.UserControls.Common" xmlns:uco="clr-namespace:SecureFolderFS.Maui.UserControls.Options" xmlns:vm="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls.Components;assembly=SecureFolderFS.Sdk" + xmlns:vmc="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls;assembly=SecureFolderFS.Sdk" x:Name="ThisPage" Title="{OnPlatform iOS={l:ResourceString Rid=Settings}, Android={x:Null}}" @@ -145,6 +146,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Settings/SettingsPage.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Settings/SettingsPage.xaml.cs index acdf38851..a333ee837 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Settings/SettingsPage.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Settings/SettingsPage.xaml.cs @@ -33,6 +33,8 @@ public partial class SettingsPage : BaseModalPage, IOverlayControl public PrivacySettingsViewModel? PrivacyViewModel { get; private set; } + public AccountsSettingsViewModel? AccountsViewModel { get; private set; } + public AboutSettingsViewModel? AboutViewModel { get; private set; } public SettingsPage(INavigation sourceNavigation) @@ -69,12 +71,14 @@ public void SetView(IViewable viewable) GeneralViewModel = OverlayViewModel.NavigationService.Views.GetOrAdd(() => new GeneralSettingsViewModel().WithInitAsync()); PreferencesViewModel = OverlayViewModel.NavigationService.Views.GetOrAdd(() => new PreferencesSettingsViewModel().WithInitAsync()); PrivacyViewModel = OverlayViewModel.NavigationService.Views.GetOrAdd(() => new PrivacySettingsViewModel().WithInitAsync()); + AccountsViewModel = OverlayViewModel.NavigationService.Views.GetOrAdd(() => new AccountsSettingsViewModel().WithInitAsync()); AboutViewModel = OverlayViewModel.NavigationService.Views.GetOrAdd(() => new AboutSettingsViewModel().WithInitAsync()); OnPropertyChanged(nameof(OverlayViewModel)); OnPropertyChanged(nameof(GeneralViewModel)); OnPropertyChanged(nameof(PreferencesViewModel)); OnPropertyChanged(nameof(PrivacyViewModel)); + OnPropertyChanged(nameof(AccountsViewModel)); OnPropertyChanged(nameof(AboutViewModel)); } diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml index e8b097898..c1d4755d2 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Vault/FilePreviewModalPage.xaml @@ -173,7 +173,17 @@ + public IVaultModel? VaultModel + { + get => (IVaultModel?)GetValue(VaultModelProperty); + set => SetValue(VaultModelProperty, value); + } + public static readonly DependencyProperty VaultModelProperty = + DependencyProperty.Register(nameof(VaultModel), typeof(IVaultModel), typeof(LoginOptions), + new PropertyMetadata(null, (d, _) => ((LoginOptions)d).LoadFileSystemOptions())); + + private async void LoadFileSystemOptions() + { + FileSystemOptionsSection.Visibility = Visibility.Collapsed; + if (VaultModel is null) + return; + + string effectiveFileSystemId; + try + { + // Resolve the file system that would actually be used to mount this vault + // (accounts for the preferred file system setting and device availability). + var fileSystem = await DI.Service().GetBestFileSystemAsync(); + effectiveFileSystemId = fileSystem.Id; + } + catch + { + // No supported file system - nothing to configure + return; + } + + var stored = VaultModel.DataModel.FileSystemOptions; + _isLoadingOptions = true; + try + { + PortOption.Visibility = Visibility.Collapsed; + DriveLetterOption.Visibility = Visibility.Collapsed; + MountPathOption.Visibility = Visibility.Collapsed; + + switch (effectiveFileSystemId) + { + case Constants.FileSystemId.FS_WEBDAV: + PortBox.Value = stored?.Port ?? 4949; + PortOption.Visibility = Visibility.Visible; + break; + +#if WINDOWS + case Core.Dokany.Constants.FileSystem.FS_ID: + case Core.WinFsp.Constants.FileSystem.FS_ID: + PopulateDriveLetters(stored?.MountPoint); + DriveLetterOption.Visibility = Visibility.Visible; + break; +#endif + + case Constants.FileSystemId.FS_FUSE: +#if __UNO_SKIA_MACOS__ + case Core.MacFuse.Constants.FileSystem.FS_ID: +#endif + MountPathBox.Text = stored?.MountPoint ?? string.Empty; + MountPathOption.Visibility = Visibility.Visible; + break; + } + + FileSystemOptionsSection.Visibility = + PortOption.Visibility == Visibility.Visible || + DriveLetterOption.Visibility == Visibility.Visible || + MountPathOption.Visibility == Visibility.Visible + ? Visibility.Visible + : Visibility.Collapsed; + } + finally + { + _isLoadingOptions = false; + } + } + + private void PopulateDriveLetters(string? storedMountPoint) + { + DriveLetterBox.Items.Clear(); + + var usedLetters = new HashSet(); + foreach (var drive in DriveInfo.GetDrives()) + { + if (drive.Name.Length > 0) + usedLetters.Add(char.ToUpperInvariant(drive.Name[0])); + } + + char? storedLetter = null; + if (!string.IsNullOrWhiteSpace(storedMountPoint)) + { + var letter = char.ToUpperInvariant(storedMountPoint[0]); + if (letter is >= 'A' and <= 'Z') + storedLetter = letter; + } + + // Skip floppy disk drives and the system drive, and offer only free letters + for (var letter = 'C'; letter <= 'Z'; letter++) + { + if (usedLetters.Contains(letter) && letter != storedLetter) + continue; + + DriveLetterBox.Items.Add($"{letter}:"); + } + + if (storedLetter is { } selected) + DriveLetterBox.SelectedItem = $"{selected}:"; + } + + private void PortBox_ValueChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) + { + if (_isLoadingOptions) + return; + + int? port = double.IsNaN(sender.Value) ? null : (int)sender.Value; + PersistFileSystemOptions(options => options.Port = port is > 0 and <= 65535 ? port : null); + } + + private void DriveLetterBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoadingOptions) + return; + + var mountPoint = DriveLetterBox.SelectedItem as string; + PersistFileSystemOptions(options => options.MountPoint = string.IsNullOrWhiteSpace(mountPoint) ? null : mountPoint); + } + + private void MountPathBox_LostFocus(object sender, RoutedEventArgs e) + { + if (_isLoadingOptions) + return; + + var mountPoint = MountPathBox.Text; + PersistFileSystemOptions(options => options.MountPoint = string.IsNullOrWhiteSpace(mountPoint) ? null : mountPoint.Trim()); + } + + private async void PersistFileSystemOptions(Action apply) + { + if (VaultModel is null) + return; + + var dataModel = VaultModel.DataModel; + dataModel.FileSystemOptions ??= new FileSystemOptionsDataModel(); + apply(dataModel.FileSystemOptions); + + await VaultModel.TrySaveAsync(); + } + public bool ShouldSaveCredentials { get => (bool)GetValue(ShouldSaveCredentialsProperty); diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Migration/MigratorV3_V4.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Migration/MigratorV3_V4.xaml new file mode 100644 index 000000000..92dd16a2f --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Migration/MigratorV3_V4.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + +