diff --git a/SecureFolderFS.slnx b/SecureFolderFS.slnx index ddfb900cc..0bd65d85d 100644 --- a/SecureFolderFS.slnx +++ b/SecureFolderFS.slnx @@ -93,6 +93,7 @@ + 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..a69e35d12 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; @@ -68,12 +70,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 +106,78 @@ 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 = await _ciphertextStream.ReadAsync(realCiphertextChunk, cancellationToken).ConfigureAwait(false); + + // 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/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..9d8dc849f 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 { @@ -40,5 +41,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..f742b95b3 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs @@ -3,6 +3,8 @@ 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; @@ -76,6 +78,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) { @@ -128,6 +142,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 + if (Inner.CanSeek) + { + if (Inner.IsEndOfStream()) + return Constants.FILE_EOF; + + 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 +238,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 +303,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 +382,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 +411,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 +463,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 +510,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 +546,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..dbe63af82 100644 --- a/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/OnDeviceMacFuse.cs +++ b/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/OnDeviceMacFuse.cs @@ -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; @@ -231,7 +232,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 +256,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 +431,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) @@ -524,7 +525,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 +541,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 +653,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; diff --git a/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.cs b/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.cs index 63750fd2b..7a08e0f56 100644 --- a/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.cs +++ b/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.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 MacFuseFileHandle(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.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..a372dad02 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -19,11 +19,15 @@ + + + + @@ -39,12 +43,12 @@ - - + +
-
\ No newline at end of file +
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/Platforms/Android/Helpers/AndroidLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs index d51d03da6..0ccf99889 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Helpers/AndroidLifecycleHelper.cs @@ -27,7 +27,7 @@ internal sealed class AndroidLifecycleHelper : BaseLifecycleHelper, IRecipient + /// 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/AndroidSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs index d1e381cd1..ff580a14f 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidSystemService.cs @@ -50,5 +50,18 @@ public Task GetAvailableFreeSpaceAsync(IFolder storageRoot, CancellationTo #endif } + /// + 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/IOSSystemService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs index 6ac8c405e..18456d13e 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSSystemService.cs @@ -37,5 +37,19 @@ public async Task GetAvailableFreeSpaceAsync(IFolder storageRoot, Cancella throw new PlatformNotSupportedException("Only implemented on iOS."); #endif } + + /// + 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/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 static void ParseSecureAuthRequest(byte[] data, out string credentialId, out byte[] challenge, out long timestamp, out byte[] requestNonce) + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + + credentialId = reader.ReadString(); + var challengeLength = reader.ReadInt32(); + challenge = reader.ReadBytes(challengeLength); + timestamp = reader.ReadInt64(); + var nonceLength = reader.ReadInt32(); + requestNonce = reader.ReadBytes(nonceLength); + } + + /// + /// Creates an authentication response (encrypted before transmission). Carries the stable + /// vault key contribution together with the echoed request nonce. + /// + public static byte[] CreateSecureAuthResponse(ReadOnlySpan keyContribution, ReadOnlySpan echoedNonce) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write(keyContribution.Length); + writer.Write(keyContribution); + writer.Write(echoedNonce.Length); + writer.Write(echoedNonce); + + return ms.ToArray(); + } + + /// + /// Parses an authentication response (already decrypted from the secure channel). + /// + public static void ParseSecureAuthResponse(byte[] data, out byte[] keyContribution, out byte[] echoedNonce) + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + + var keyLength = reader.ReadInt32(); + keyContribution = reader.ReadBytes(keyLength); + var nonceLength = reader.ReadInt32(); + echoedNonce = reader.ReadBytes(nonceLength); + } + public static byte[] CreateAuthenticationRejected(string reason) { using var ms = new MemoryStream(); diff --git a/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Models/SecureChannelModel.cs b/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Models/SecureChannelModel.cs index dc418e84c..00570f91e 100644 --- a/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Models/SecureChannelModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Models/SecureChannelModel.cs @@ -17,6 +17,8 @@ public sealed class SecureChannelModel : IDisposable /// private const int REPLAY_WINDOW_SIZE = 64; + private static readonly byte[] ENCRYPTION_INFO = "DeviceLink-Encryption-v1"u8.ToArray(); + private readonly byte[] _encryptionKey; private readonly object _replayLock = new(); private ulong _replayWindow; // Bitmap for tracking received sequences within window @@ -27,16 +29,48 @@ public sealed class SecureChannelModel : IDisposable /// /// Creates a secure channel from a shared secret. /// - public SecureChannelModel(byte[] sharedSecret, byte[]? salt = null) + /// The (typically ephemeral) shared secret negotiated for this channel. + /// Optional salt mixed into the key derivation (e.g. session nonces). + /// + /// An optional long-term secret established during pairing (known only to the two paired devices). + /// When supplied, it is folded into the key derivation so that only a peer that possesses this + /// secret can derive the same channel key. This authenticates an otherwise-anonymous ephemeral + /// key exchange to the established pairing: a network attacker who completes the handshake but + /// does not know the binding secret derives a different key, so every AES-GCM operation fails + /// and it can neither read nor forge channel traffic. + /// + public SecureChannelModel(byte[] sharedSecret, byte[]? salt = null, byte[]? bindingSecret = null) { salt ??= []; - _encryptionKey = HKDF.DeriveKey( - HashAlgorithmName.SHA256, - sharedSecret, - 32, - salt, - "DeviceLink-Encryption-v1"u8.ToArray()); + // Domain-separation label, optionally extended with the pairing binding secret. + byte[] info; + if (bindingSecret is { Length: > 0 }) + { + info = new byte[ENCRYPTION_INFO.Length + bindingSecret.Length]; + ENCRYPTION_INFO.CopyTo(info, 0); + bindingSecret.CopyTo(info, ENCRYPTION_INFO.Length); + } + else + { + info = ENCRYPTION_INFO; + } + + try + { + _encryptionKey = HKDF.DeriveKey( + HashAlgorithmName.SHA256, + sharedSecret, + 32, + salt, + info); + } + finally + { + // Zero the temporary copy that contained the binding secret. + if (bindingSecret is { Length: > 0 }) + CryptographicOperations.ZeroMemory(info); + } } /// diff --git a/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Results/DeviceLinkPairingResult.cs b/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Results/DeviceLinkPairingResult.cs index bafb94468..7fb66aed7 100644 --- a/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Results/DeviceLinkPairingResult.cs +++ b/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Results/DeviceLinkPairingResult.cs @@ -31,6 +31,13 @@ public sealed class DeviceLinkPairingResult : Result /// public required string MobileDeviceType { get; init; } + /// + /// The channel binding secret established during pairing. Persisted on the desktop and folded + /// into every authentication session's channel key. Domain-separated from the vault key + /// contribution, so storing it at rest does not expose any vault key material. + /// + public required byte[] BindingSecret { get; init; } + public DeviceLinkPairingResult(IKeyBytes value) : base(value) { diff --git a/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Services/DeviceLinkService.cs b/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Services/DeviceLinkService.cs index 2dca2486d..09988bcb6 100644 --- a/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Services/DeviceLinkService.cs +++ b/src/Sdk/SecureFolderFS.Sdk.DeviceLink/Services/DeviceLinkService.cs @@ -261,6 +261,12 @@ await SafetyHelpers.NoFailureAsync(async () => return; } + // The verification code confirmed the hybrid key exchange is free of an active MITM, + // so encrypt all subsequent pairing messages under the trusted shared secret. The + // confirm carries the persistent challenge and the complete carries the HMAC key + // contribution - neither may travel in cleartext. + using var pairingChannel = new SecureChannelModel(sharedSecret); + // Now wait for pairing confirmation from desktop (which includes vault info) var confirmMessage = await device.ReceiveMessageAsync(cancellationToken); var confirmType = (MessageType)confirmMessage[0]; @@ -271,8 +277,10 @@ await SafetyHelpers.NoFailureAsync(async () => if (confirmType != MessageType.PairingConfirm) return; - // Parse pairing confirmation - ProtocolSerializer.ParsePairingConfirm(confirmMessage, out var credentialId, out var vaultName, out var pairingId, out var challenge); + // Decrypt and parse pairing confirmation + var confirmPayload = confirmMessage.AsSpan(Constants.KeyTraits.MESSAGE_BYTE_LENGTH); + var decryptedConfirm = pairingChannel.Decrypt(confirmPayload); + ProtocolSerializer.ParsePairingConfirm(decryptedConfirm, out var credentialId, out var vaultName, out var pairingId, out var challenge); // Create and enroll credential with persistent challenge var credential = new CredentialViewModel() @@ -300,19 +308,26 @@ await _credentialStoreModel.EnrollCredentialAsync( try { - // Decrypt HMAC key so we can compute HMAC + // Decrypt HMAC key so we can compute the derived values credential.DecryptHmacKey(encryptionKey); - // Compute HMAC over the persistent challenge data - var challengeData = BuildChallengeData(credentialId, challenge); - var hmacResult = credential.ComputeHmac(challengeData); + // Two independent, domain-separated derivations of the HMAC key: + // 1. The key contribution feeds the vault's key derivation on the desktop and is + // never persisted there (the desktop keeps only a hash to verify responses). + // 2. The binding secret authenticates future session channels and is the only + // secret the desktop stores at rest. Knowing it does not reveal the key contribution. + var keyContribution = credential.ComputeHmac(BuildChallengeData(credentialId, challenge)); + var bindingSecret = credential.ComputeHmac(BuildBindingData(credentialId, challenge)); - // Send pairing complete with HMAC result - var completeMessage = ProtocolSerializer.CreatePairingComplete(hmacResult); - await device.SendMessageAsync(completeMessage, cancellationToken); + // Send pairing complete with both values (encrypted over the pairing channel) + var completeMessage = ProtocolSerializer.CreatePairingComplete(keyContribution, bindingSecret); + var encryptedComplete = pairingChannel.Encrypt(completeMessage); + await device.SendMessageAsync(encryptedComplete, MessageType.PairingComplete, cancellationToken); // Clear the decrypted key from memory credential.ClearDecryptedKey(); + CryptographicOperations.ZeroMemory(keyContribution); + CryptographicOperations.ZeroMemory(bindingSecret); // Notify UI that enrollment is complete EnrollmentCompleted?.Invoke(this, credential); @@ -386,8 +401,40 @@ private async Task HandleSecureSessionRequestAsync(ConnectedDevice device, Conne desktopNonce.CopyTo(combinedNonce, 0); mobileNonce.CopyTo(combinedNonce, desktopNonce.Length); - session.SecureChannel = new SecureChannelModel(sharedSecret, combinedNonce); - CryptographicOperations.ZeroMemory(sharedSecret); + // Compute the pairing binding secret (== the desktop's stored BindingSecret) and fold it + // into the channel key. Only a device that holds this credential's HMAC key can reproduce + // it, which authenticates this otherwise-anonymous ephemeral handshake to the established + // pairing: an active MITM or a device-id impersonator that lacks the secret derives a + // different key and is rejected by AES-GCM authentication, so it can neither read the + // challenge nor capture the key contribution in the response. + var encryptionKey = await _credentialStoreModel.GetEncryptionKeyAsync(pairingId); + if (encryptionKey is null || credential.CredentialId is null || credential.Challenge is null) + { + CryptographicOperations.ZeroMemory(sharedSecret); + if (encryptionKey is not null) + CryptographicOperations.ZeroMemory(encryptionKey); + + await device.SendMessageAsync(ProtocolSerializer.CreateAuthenticationRejected("Key error"), cancellationToken); + return; + } + + byte[]? bindingSecret = null; + try + { + credential.DecryptHmacKey(encryptionKey); + var bindingData = BuildBindingData(credential.CredentialId, credential.Challenge); + bindingSecret = credential.ComputeHmac(bindingData); + + session.SecureChannel = new SecureChannelModel(sharedSecret, combinedNonce, bindingSecret); + } + finally + { + credential.ClearDecryptedKey(); + CryptographicOperations.ZeroMemory(encryptionKey); + CryptographicOperations.ZeroMemory(sharedSecret); + if (bindingSecret is not null) + CryptographicOperations.ZeroMemory(bindingSecret); + } // Send response with our ECDH public key and ML-KEM ciphertext var response = ProtocolSerializer.CreateSecureSessionAccepted(mobileNonce, myEcdhPublicKey, mlKemCiphertext); @@ -409,14 +456,8 @@ private async Task HandleSecureAuthRequestAsync(ConnectedDevice device, Connecti var encryptedPayload = message.AsSpan(Constants.KeyTraits.MESSAGE_BYTE_LENGTH); var decryptedPayload = session.SecureChannel.Decrypt(encryptedPayload); - // Parse request (persistent challenge from desktop) - using var ms = new MemoryStream(decryptedPayload); - using var reader = new BinaryReader(ms); - - var credentialId = reader.ReadString(); - var challengeLength = reader.ReadInt32(); - var persistentChallenge = reader.ReadBytes(challengeLength); - var timestamp = reader.ReadInt64(); + // Parse request (persistent challenge + fresh request nonce from desktop) + ProtocolSerializer.ParseSecureAuthRequest(decryptedPayload, out var credentialId, out var persistentChallenge, out var timestamp, out var requestNonce); // Validate timestamp (for replay protection) var requestTime = DateTimeOffset.FromUnixTimeSeconds(timestamp); @@ -476,26 +517,30 @@ private async Task HandleSecureAuthRequestAsync(ConnectedDevice device, Connecti return; } + byte[]? keyContribution = null; try { session.CurrentCredential.DecryptHmacKey(encryptionKey); - // Compute HMAC over the challenge data - var challengeData = BuildChallengeData(credentialId, persistentChallenge); - var hmacResult = session.CurrentCredential.ComputeHmac(challengeData); + // Compute the vault key contribution and echo the desktop's request nonce. + // The echoed nonce proves this response was produced for this exact request. + keyContribution = session.CurrentCredential.ComputeHmac(BuildChallengeData(credentialId, persistentChallenge)); + var responsePayload = ProtocolSerializer.CreateSecureAuthResponse(keyContribution, requestNonce); // Encrypt and send response - var encryptedHmac = session.SecureChannel.Encrypt(hmacResult); - await device.SendMessageAsync(encryptedHmac, MessageType.SecureAuthResponse, cancellationToken); + var encryptedResponse = session.SecureChannel.Encrypt(responsePayload); + await device.SendMessageAsync(encryptedResponse, MessageType.SecureAuthResponse, cancellationToken); // Notify UI that authentication completed successfully AuthenticationCompleted?.Invoke(this, EventArgs.Empty); } finally { - // Clear decrypted key from memory + // Clear decrypted key material from memory session.CurrentCredential.ClearDecryptedKey(); CryptographicOperations.ZeroMemory(encryptionKey); + if (keyContribution is not null) + CryptographicOperations.ZeroMemory(keyContribution); } } catch (CryptographicException) @@ -506,8 +551,7 @@ await SafetyHelpers.NoFailureAsync(async () => } /// - /// Builds the data for HMAC computation. - /// Must match desktop's BuildChallengeData exactly. + /// Builds the HMAC input for the vault key contribution. /// private static byte[] BuildChallengeData(string credentialId, byte[] persistentChallenge) { @@ -520,6 +564,23 @@ private static byte[] BuildChallengeData(string credentialId, byte[] persistentC return ms.ToArray(); } + /// + /// Builds the HMAC input for the channel binding secret. The domain-separation label keeps it + /// cryptographically independent from the vault key contribution: the desktop persists the + /// binding secret at rest, and knowing it must not reveal the key contribution. + /// + private static byte[] BuildBindingData(string credentialId, byte[] persistentChallenge) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write("DeviceLink-Binding-v1"u8); + writer.Write(Encoding.UTF8.GetBytes(credentialId)); + writer.Write(persistentChallenge); + + return ms.ToArray(); + } + #endregion public void Dispose() diff --git a/src/Sdk/SecureFolderFS.Sdk.WebDavClient/Storage/DavClientFolder.cs b/src/Sdk/SecureFolderFS.Sdk.WebDavClient/Storage/DavClientFolder.cs index 58c846754..475a6df39 100644 --- a/src/Sdk/SecureFolderFS.Sdk.WebDavClient/Storage/DavClientFolder.cs +++ b/src/Sdk/SecureFolderFS.Sdk.WebDavClient/Storage/DavClientFolder.cs @@ -82,63 +82,15 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default) { var path = CombinePath(Id, name); - - // Try as a collection first (with trailing slash) to avoid 301 redirect - // which causes SocketsHttpHandler to strip the Authorization header - var folderPath = path.EndsWith('/') ? path : path + "/"; - var folderUri = ResolveUri(folderPath); - var propfindParams = new PropfindParameters() - { - CancellationToken = cancellationToken - }; - - var response = await davClient.Propfind(folderUri, propfindParams); - if (response.IsSuccessful && response.Resources.Any()) - { - var resource = response.Resources.First(); - if (resource.IsCollection) - return new DavClientFolder(davClient, httpClient, baseUri, folderPath, name, this); - } - - // Fall back to file (no trailing slash) - var fileUri = ResolveUri(path); - response = await davClient.Propfind(fileUri, propfindParams); - if (!response.IsSuccessful || !response.Resources.Any()) - throw new FileNotFoundException($"Item with name '{name}' was not found in folder '{Name}'."); - - return new DavClientFile(davClient, httpClient, baseUri, path, name, this); + var resolved = await ResolveStorableAsync(path, cancellationToken); + return resolved ?? throw new FileNotFoundException($"Item with name '{name}' was not found in folder '{Name}'."); } /// public async Task GetItemAsync(string id, CancellationToken cancellationToken = default) { - // Try as collection first (trailing slash) to avoid 301 redirect stripping Authorization header - var folderPath = id.EndsWith('/') ? id : id + "/"; - var folderUri = ResolveUri(folderPath); - var propfindParams = new PropfindParameters() - { - CancellationToken = cancellationToken - }; - - var response = await davClient.Propfind(folderUri, propfindParams); - if (response.IsSuccessful && response.Resources.Any()) - { - var resource = response.Resources.First(); - if (resource.IsCollection) - { - var name = Uri.UnescapeDataString(folderPath.TrimEnd('/').Split('/').Last(s => !string.IsNullOrEmpty(s))); - return new DavClientFolder(davClient, httpClient, baseUri, folderPath, name, this); - } - } - - // Fall back to file - var fileUri = ResolveUri(id); - response = await davClient.Propfind(fileUri, propfindParams); - if (!response.IsSuccessful || !response.Resources.Any()) - throw new FileNotFoundException($"Item with id '{id}' was not found."); - - var fileName = Uri.UnescapeDataString(id.TrimEnd('/').Split('/').Last(s => !string.IsNullOrEmpty(s))); - return new DavClientFile(davClient, httpClient, baseUri, id, fileName, this); + var resolved = await ResolveStorableAsync(id, cancellationToken); + return resolved ?? throw new FileNotFoundException($"Item with id '{id}' was not found."); } /// @@ -204,24 +156,13 @@ public async Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite CancellationToken = cancellationToken }; + // COPY's destination URI already includes the final name, so the server places the copy + // directly at destPath — no follow-up rename is needed. (The previous code then issued a + // MOVE from destPath onto itself — renamedPath == destPath — which WebDAV rejects with 403.) var response = await davClient.Copy(sourceUri, destUri, copyParams); if (!response.IsSuccessful) throw new IOException($"Failed to copy '{fileToCopy.Name}' to '{newName}': {response.StatusCode}"); - // If the name differs from what COPY produced, MOVE to the correct name - if (fileToCopy.Name != newName) - { - var renamedPath = CombinePath(Id, newName); - var renamedUri = ResolveUri(renamedPath); - var moveParams = new MoveParameters() { CancellationToken = cancellationToken }; - - var moveResponse = await davClient.Move(destUri, renamedUri, moveParams); - if (!moveResponse.IsSuccessful) - throw new IOException($"Failed to rename copy to '{newName}': {moveResponse.StatusCode}"); - - return new DavClientFile(davClient, httpClient, baseUri, renamedPath, newName, this); - } - return new DavClientFile(davClient, httpClient, baseUri, destPath, newName, this); } @@ -316,5 +257,99 @@ public async Task CreateFileAsync(string name, bool overwrite = fals return new DavClientFile(davClient, httpClient, baseUri, id, name, this); } + + /// + /// Resolves a WebDAV path to a file or folder storable using a single PROPFIND per probe. + /// + /// + /// Two addressing forms exist for the same resource: no trailing slash (file) and trailing + /// slash (collection). The probe order is platform-dependent: + /// + /// Browser (WASM): file-first. A trailing slash on a file is a path the server + /// doesn't have, so its CORS preflight returns a non-OK status and + /// the browser blocks the request. Probing the no-slash form first avoids generating that + /// failing request for the common case (all vault config files are files). + /// Elsewhere: folder-first, to dodge the server's 301 redirect from + /// dir to dir/, which makes drop the + /// Authorization header on the redirected request. + /// + /// + private async Task ResolveStorableAsync(string path, CancellationToken cancellationToken) + { + var trimmed = path.TrimEnd('/'); + var filePath = trimmed; + var folderPath = trimmed + "/"; + var name = Uri.UnescapeDataString(trimmed.Split('/').Last(s => !string.IsNullOrEmpty(s))); + + var propfindParams = new PropfindParameters() + { + CancellationToken = cancellationToken + }; + + if (OperatingSystem.IsBrowser()) + { + // File-first + var asFile = await TryProbeAsync(filePath, propfindParams); + if (asFile is not null) + { + return asFile == StorableType.Folder + ? new DavClientFolder(davClient, httpClient, baseUri, folderPath, name, this) + : new DavClientFile(davClient, httpClient, baseUri, filePath, name, this); + } + + var asFolder = await TryProbeAsync(folderPath, propfindParams); + if (asFolder is not null) + return new DavClientFolder(davClient, httpClient, baseUri, folderPath, name, this); + + return null; + } + + // Folder-first (non-browser) + var folderProbe = await TryProbeAsync(folderPath, propfindParams); + if (folderProbe == StorableType.Folder) + return new DavClientFolder(davClient, httpClient, baseUri, folderPath, name, this); + + var fileProbe = await TryProbeAsync(filePath, propfindParams); + if (fileProbe is not null) + { + return fileProbe == StorableType.Folder + ? new DavClientFolder(davClient, httpClient, baseUri, folderPath, name, this) + : new DavClientFile(davClient, httpClient, baseUri, filePath, name, this); + } + + return null; + } + + /// + /// Performs a Depth:0 PROPFIND on . Returns whether the resolved + /// resource is a collection or a file, or null when the resource does not exist / the request + /// failed (including a failed CORS preflight in the browser, which is only ever a negative + /// signal here). + /// + private async Task TryProbeAsync(string probePath, PropfindParameters propfindParams) + { + try + { + var response = await davClient.Propfind(ResolveUri(probePath), propfindParams); + if (!response.IsSuccessful || !response.Resources.Any()) + return null; + + // Prefer the resource that matches the probed path; fall back to the first entry + // (a Depth:0 PROPFIND returns the addressed resource itself). + var trimmedProbe = probePath.TrimEnd('/'); + var resource = response.Resources.FirstOrDefault(r => (r.Uri ?? string.Empty).TrimEnd('/').EndsWith(trimmedProbe, StringComparison.Ordinal)) + ?? response.Resources.First(); + + return resource.IsCollection ? StorableType.Folder : StorableType.File; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return null; + } + } } } diff --git a/src/Sdk/SecureFolderFS.Sdk.WebDavClient/Streams/DavClientWriteStream.cs b/src/Sdk/SecureFolderFS.Sdk.WebDavClient/Streams/DavClientWriteStream.cs index 4c4155986..020eb45a8 100644 --- a/src/Sdk/SecureFolderFS.Sdk.WebDavClient/Streams/DavClientWriteStream.cs +++ b/src/Sdk/SecureFolderFS.Sdk.WebDavClient/Streams/DavClientWriteStream.cs @@ -116,13 +116,38 @@ protected override void Dispose(bool disposing) if (!_disposed && disposing) { _disposed = true; - Task.Run(UploadAsync).GetAwaiter().GetResult(); - _buffer.Dispose(); + if (OperatingSystem.IsBrowser()) + { + // The browser runtime is single-threaded: blocking on the upload would throw + // (and could never complete anyway). Fire-and-forget is the best a synchronous Dispose() can do + _ = UploadAndReleaseAsync(); + } + else + { + Task.Run(UploadAsync).GetAwaiter().GetResult(); + _buffer.Dispose(); + } } base.Dispose(disposing); } + private async Task UploadAndReleaseAsync() + { + try + { + await UploadAsync(); + } + catch + { + // Nothing to propagate to from a fire-and-forget upload + } + finally + { + await _buffer.DisposeAsync(); + } + } + /// public override async ValueTask DisposeAsync() { diff --git a/src/Sdk/SecureFolderFS.Sdk.WebDavClient/ViewModels/WebDavClientAccountViewModel.cs b/src/Sdk/SecureFolderFS.Sdk.WebDavClient/ViewModels/WebDavClientAccountViewModel.cs index 6f0f51bdd..840a5928c 100644 --- a/src/Sdk/SecureFolderFS.Sdk.WebDavClient/ViewModels/WebDavClientAccountViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk.WebDavClient/ViewModels/WebDavClientAccountViewModel.cs @@ -166,55 +166,23 @@ private async Task ConnectAsync(string? address, string? port, string? ? $"http://{address}" : address; - // Build the base URI with an optional custom port + // Build the base URI. A port typed inside the address is preserved; the dedicated + // port value (when provided) takes precedence over it. var uriBuilder = new UriBuilder(normalizedAddress); if (int.TryParse(port, out var portValue) && portValue > 0) uriBuilder.Port = portValue; - else - uriBuilder.Port = -1; // Use the default port for the scheme if (!uriBuilder.Path.EndsWith('/')) uriBuilder.Path += '/'; _baseUri = uriBuilder.Uri; - var normalizedManualFingerprint = NormalizeFingerprint(manualCertificateFingerprint); - var normalizedTrustedFingerprint = NormalizeFingerprint(trustedCertificateFingerprint); - // Use SocketsHttpHandler to support non-standard HTTP methods (PROPFIND, MKCOL, etc.) - // The platform-default handler on platforms like Android (AndroidMessageHandler) uses java.net.HttpURLConnection - // which rejects non-standard HTTP methods with a ProtocolException. - _httpClient = new HttpClient(new SocketsHttpHandler() - { - PreAuthenticate = false, - SslOptions = - { - RemoteCertificateValidationCallback = (_, certificate, _, sslPolicyErrors) => - { - var serverFingerprint = GetCertificateFingerprint(certificate); - if (string.IsNullOrEmpty(serverFingerprint)) - return false; - - // Manual pinning takes precedence over TOFU/default validation. - if (!string.IsNullOrWhiteSpace(normalizedManualFingerprint)) - return string.Equals(serverFingerprint, normalizedManualFingerprint, StringComparison.OrdinalIgnoreCase); - - if (!string.IsNullOrWhiteSpace(normalizedTrustedFingerprint)) - return string.Equals(serverFingerprint, normalizedTrustedFingerprint, StringComparison.OrdinalIgnoreCase); - - if (acceptFirstCertificate) - { - normalizedTrustedFingerprint = serverFingerprint; - TrustedCertificateFingerprint = serverFingerprint; - return true; - } - - return sslPolicyErrors == SslPolicyErrors.None; - } - } - }) + // The timeout must not apply to the whole client: it would abort every later file + // transfer through this connection after 5 seconds. Only the verification PROPFIND below is capped (see timeoutCts). + _httpClient = new HttpClient(CreateHandler(acceptFirstCertificate, manualCertificateFingerprint, trustedCertificateFingerprint)) { BaseAddress = _baseUri, - Timeout = TimeSpan.FromSeconds(5) + Timeout = Timeout.InfiniteTimeSpan }; if (!string.IsNullOrEmpty(username)) @@ -225,10 +193,13 @@ private async Task ConnectAsync(string? address, string? port, string? _webDavClient = new WebDav.WebDavClient(_httpClient); - // Verify the connection by performing a PROPFIND on root + // Verify the connection by performing a PROPFIND on root (capped so a dead server + // fails fast without limiting later transfers on the same client) + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); var propfindParams = new PropfindParameters { - CancellationToken = cancellationToken, + CancellationToken = timeoutCts.Token, Headers = new List>() }; @@ -246,6 +217,57 @@ private async Task ConnectAsync(string? address, string? port, string? } } + /// + /// Creates the HTTP handler for the platform: + /// + /// Browser (WASM): the default handler, which routes through the fetch API. + /// is unavailable there (its constructor throws + /// ), and TLS validation/certificate pinning + /// is owned by the browser itself. + /// Everywhere else: , needed for non-standard HTTP + /// methods (PROPFIND, MKCOL, etc.) - platform-default handlers such as Android's + /// AndroidMessageHandler reject them - with fingerprint-pinning TLS validation. + /// + /// + private HttpMessageHandler CreateHandler(bool acceptFirstCertificate, string? manualCertificateFingerprint, string? trustedCertificateFingerprint) + { + if (OperatingSystem.IsBrowser()) + return new HttpClientHandler(); + + var normalizedManualFingerprint = NormalizeFingerprint(manualCertificateFingerprint); + var normalizedTrustedFingerprint = NormalizeFingerprint(trustedCertificateFingerprint); + + return new SocketsHttpHandler() + { + PreAuthenticate = false, + SslOptions = + { + RemoteCertificateValidationCallback = (_, certificate, _, sslPolicyErrors) => + { + var serverFingerprint = GetCertificateFingerprint(certificate); + if (string.IsNullOrEmpty(serverFingerprint)) + return false; + + // Manual pinning takes precedence over TOFU/default validation. + if (!string.IsNullOrWhiteSpace(normalizedManualFingerprint)) + return string.Equals(serverFingerprint, normalizedManualFingerprint, StringComparison.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(normalizedTrustedFingerprint)) + return string.Equals(serverFingerprint, normalizedTrustedFingerprint, StringComparison.OrdinalIgnoreCase); + + if (acceptFirstCertificate) + { + normalizedTrustedFingerprint = serverFingerprint; + TrustedCertificateFingerprint = serverFingerprint; + return true; + } + + return sslPolicyErrors == SslPolicyErrors.None; + } + } + }; + } + private void UpdateInputValidation() { IsInputFilled = !string.IsNullOrEmpty(Address); diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/ForwardOnlyWriteStream.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/ForwardOnlyWriteStream.cs new file mode 100644 index 000000000..d5a00c479 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/ForwardOnlyWriteStream.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SecureFolderFS.Sdk.AppModels +{ + /// + /// A write-only, non-seekable wrapper over another stream that forwards writes sequentially. + /// + internal sealed class ForwardOnlyWriteStream : Stream + { + private readonly Stream _inner; + private long _position; + + public ForwardOnlyWriteStream(Stream inner) + { + _inner = inner; + } + + /// + public override bool CanRead => false; + + /// + public override bool CanSeek => false; + + /// + public override bool CanWrite => true; + + /// + public override long Length => _position; + + /// + public override long Position + { + // Tracked as the running number of bytes written, so callers can record offsets + // without the stream needing to be seekable + get => _position; + set => throw new NotSupportedException(); + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + _inner.Write(buffer, offset, count); + _position += count; + } + + /// + public override void Write(ReadOnlySpan buffer) + { + _inner.Write(buffer); + _position += buffer.Length; + } + + /// + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + await _inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + _position += count; + } + + /// + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await _inner.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + _position += buffer.Length; + } + + /// + public override void Flush() => _inner.Flush(); + + /// + public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken); + + /// + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + /// + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + /// + public override void SetLength(long value) => throw new NotSupportedException(); + + // Intentionally does not dispose the wrapped stream - its owner retains that responsibility + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/AppModels/Sorters/DateSorter.cs b/src/Sdk/SecureFolderFS.Sdk/AppModels/Sorters/DateSorter.cs new file mode 100644 index 000000000..c38f32ecd --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/AppModels/Sorters/DateSorter.cs @@ -0,0 +1,40 @@ +using System; +using SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser; +using SecureFolderFS.Shared.ComponentModel; + +namespace SecureFolderFS.Sdk.AppModels.Sorters +{ + public sealed class DateSorter : BaseFolderSorter + { + private readonly bool _isAscending; + + public static IItemSorter Ascending { get; } = new DateSorter(true); + + public static IItemSorter Descending { get; } = new DateSorter(false); + + private DateSorter(bool isAscending) + { + _isAscending = isAscending; + } + + /// + public override int Compare(BrowserItemViewModel? x, BrowserItemViewModel? y) + { + if (x is null || y is null) + return 0; + + // Ensure folders come before files + var xIsFolder = x is FolderViewModel; + var yIsFolder = y is FolderViewModel; + + if (xIsFolder && !yIsFolder) + return -1; + + if (!xIsFolder && yIsFolder) + return 1; + + var result = DateTime.Compare(x.LastModified ?? DateTime.MinValue, y.LastModified ?? DateTime.MinValue); + return _isAscending ? result : -result; + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/Constants.cs b/src/Sdk/SecureFolderFS.Sdk/Constants.cs index 6ee498e7e..ff2f30891 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Constants.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Constants.cs @@ -42,6 +42,12 @@ public static class Dialogs public static class Vault { + public static class Authentication + { + // (Cannot reference SecureFolderFS.Core Constants.Vault.Authentication) + public const string AUTH_RECOVERY_KEY_REQUIREMENT = "recovery_key_requirement"; + } + public const int MAX_FREE_AMOUNT_OF_VAULTS = 2; public const string VAULT_ICON_FILENAME = "vault_icon"; public const string VAULT_ICON_FILENAME_ICO = "vault_icon.ico"; diff --git a/src/Sdk/SecureFolderFS.Sdk/EventArguments/RecoveryRequestedEventArgs.cs b/src/Sdk/SecureFolderFS.Sdk/EventArguments/RecoveryRequestedEventArgs.cs new file mode 100644 index 000000000..8037bd832 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/EventArguments/RecoveryRequestedEventArgs.cs @@ -0,0 +1,16 @@ +using System; + +namespace SecureFolderFS.Sdk.EventArguments +{ + /// + /// Event arguments for requesting the vault to be unlocked using a recovery key. + /// + /// The recovery key provided by the user. + public sealed class RecoveryRequestedEventArgs(string recoveryKey) : EventArgs + { + /// + /// Gets the recovery key that should be used to unlock the vault. + /// + public string RecoveryKey { get; } = recoveryKey; + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs b/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs index 0ed6ee4b5..bebe53743 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationExtensions.cs @@ -66,5 +66,30 @@ public static string ToLocalized(this string resourceKey, ILocalizationService l var localized = ToLocalized(resourceKey, localizationService); return SafetyHelpers.NoFailureResult(() => Smart.Format(localizationService.CurrentCulture, localized, interpolate)) ?? $"{{{resourceKey}}}"; } + + /// + /// Converts a to a localized, human-readable string representation. + /// + /// The localization service used to retrieve localized strings and culture information. + /// The date and time to localize. + /// A localized string representation of the date. + public static string LocalizeDate(this ILocalizationService localizationService, DateTime dateTime) + { + var cultureInfo = localizationService.CurrentCulture; + var daysAgo = (DateTime.Today - dateTime.Date).Days; + var weeksAgo = daysAgo / 7; + var dateString = dateTime switch + { + _ when dateTime.Year == 1 => "Unspecified", + _ when dateTime.Date == DateTime.Today => "DateToday".ToLocalized(localizationService, interpolate: dateTime.ToString("t", cultureInfo)), + _ when daysAgo == 1 => "DateYesterday".ToLocalized(localizationService, interpolate: dateTime.ToString("t", cultureInfo)), + _ when daysAgo is >= 2 and <= 6 => "DateDaysAgoPlural".ToLocalized(localizationService, interpolate: daysAgo), + _ when daysAgo is >= 7 and < 14 => "DateWeekAgoPlural".ToLocalized(localizationService, interpolate: weeksAgo), + _ => null + }; + + dateString ??= $"{dateTime.ToString("d", cultureInfo)}, {dateTime.ToString("t", cultureInfo)}"; + return dateString; + } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationServiceExtensions.cs b/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationServiceExtensions.cs deleted file mode 100644 index 1b0d5bdbf..000000000 --- a/src/Sdk/SecureFolderFS.Sdk/Extensions/LocalizationServiceExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using SecureFolderFS.Sdk.Services; - -namespace SecureFolderFS.Sdk.Extensions -{ - public static class LocalizationServiceExtensions - { - /// - /// Converts a to a localized, human-readable string representation. - /// - /// The localization service used to retrieve localized strings and culture information. - /// The date and time to localize. - /// A localized string representation of the date. - public static string LocalizeDate(this ILocalizationService localizationService, DateTime dateTime) - { - var cultureInfo = localizationService.CurrentCulture; - var daysAgo = (DateTime.Today - dateTime.Date).Days; - var weeksAgo = daysAgo / 7; - var dateString = dateTime switch - { - _ when dateTime.Year == 1 => "Unspecified", - _ when dateTime.Date == DateTime.Today => "DateToday".ToLocalized(localizationService, interpolate: dateTime.ToString("t", cultureInfo)), - _ when daysAgo == 1 => "DateYesterday".ToLocalized(localizationService, interpolate: dateTime.ToString("t", cultureInfo)), - _ when daysAgo is >= 2 and <= 6 => "DateDaysAgoPlural".ToLocalized(localizationService, interpolate: daysAgo), - _ when daysAgo is >= 7 and < 14 => "DateWeekAgoPlural".ToLocalized(localizationService, interpolate: weeksAgo), - _ => null - }; - - dateString ??= $"{dateTime.ToString("d", cultureInfo)}, {dateTime.ToString("t", cultureInfo)}"; - return dateString; - } - } -} diff --git a/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs b/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs index 25b303519..6f3408526 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Extensions/TransferExtensions.cs @@ -24,8 +24,9 @@ public static async Task HideAsync(this TransferViewModel transferViewModel) transferViewModel.IsPickingFolder = false; transferViewModel.IsVisible = false; - await Task.Delay(350); + await Task.Delay(TransferViewModel.HIDE_ANIMATION_DURATION_MS); transferViewModel.IsProgressing = false; + transferViewModel.IsSuccess = false; } public static async Task TransferAsync( @@ -47,6 +48,7 @@ public static async Task TransferAsync( { var collection = items.ToOrAsCollection(); transferViewModel.ClearError(); + transferViewModel.IsSuccess = false; transferViewModel.IsProgressing = true; transferViewModel.IsVisible = true; transferViewModel.Report(new(0, collection.Count, collection.Count)); @@ -106,6 +108,7 @@ public static async Task TransferAsync( { var collection = items.ToOrAsCollection(); transferViewModel.ClearError(); + transferViewModel.IsSuccess = false; transferViewModel.IsProgressing = true; transferViewModel.IsVisible = true; transferViewModel.Report(new(0, collection.Count, collection.Count)); @@ -156,6 +159,7 @@ public static async Task PerformOperationAsync(this TransferViewModel transferVi _ => string.Empty }; transferViewModel.CanCancel = cancellationToken != CancellationToken.None; + transferViewModel.IsSuccess = false; transferViewModel.IsProgressing = true; // Start a task that will show the UI after a delay if the operation is still running @@ -169,6 +173,7 @@ public static async Task PerformOperationAsync(this TransferViewModel transferVi if (uiShown) { transferViewModel.Title = "TransferDone".ToLocalized(); + transferViewModel.IsSuccess = true; await Task.Delay(300, CancellationToken.None); // Allow user to see the "Done" message } } diff --git a/src/Sdk/SecureFolderFS.Sdk/Models/AccountModel.cs b/src/Sdk/SecureFolderFS.Sdk/Models/AccountModel.cs new file mode 100644 index 000000000..478e8b4b9 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/Models/AccountModel.cs @@ -0,0 +1,15 @@ +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.ComponentModel; + +namespace SecureFolderFS.Sdk.Models +{ + /// + /// A provider-agnostic, UI-facing description of an account managed on this device + /// + /// The stable, unique identifier of the account within its provider. + /// The human-friendly display name (typically the user's email). + /// An optional secondary line (e.g., the server URL). + /// An optional icon representing the account or its provider. + /// The id of the that owns this account. + public sealed record AccountModel(string Id, string? DisplayName, string? Subtitle, IImage? Icon, string ProviderId); +} diff --git a/src/Sdk/SecureFolderFS.Sdk/SecureFolderFS.Sdk.csproj b/src/Sdk/SecureFolderFS.Sdk/SecureFolderFS.Sdk.csproj index 6be007f7b..32a3f4e02 100644 --- a/src/Sdk/SecureFolderFS.Sdk/SecureFolderFS.Sdk.csproj +++ b/src/Sdk/SecureFolderFS.Sdk/SecureFolderFS.Sdk.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/IAccountProvider.cs b/src/Sdk/SecureFolderFS.Sdk/Services/IAccountProvider.cs new file mode 100644 index 000000000..15e4441b1 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/Services/IAccountProvider.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Models; + +namespace SecureFolderFS.Sdk.Services +{ + /// + /// Provides and manages a set of accounts of a particular kind. + /// + /// + /// Providers are resolved as a collection, so platforms register zero or more implementations. + /// + public interface IAccountProvider + { + /// + /// Gets the stable identifier of this provider. + /// + string ProviderId { get; } + + /// + /// Gets all accounts currently stored on this device for this provider. + /// + Task> GetAccountsAsync(CancellationToken cancellationToken = default); + + /// + /// Removes an account and all of its locally stored material. + /// + Task RemoveAccountAsync(string accountId, CancellationToken cancellationToken = default); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/IApplicationService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/IApplicationService.cs index fd4c41d5d..57a2a8e9c 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/IApplicationService.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/IApplicationService.cs @@ -1,16 +1,16 @@ -using SecureFolderFS.Sdk.ViewModels.Controls; -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using SecureFolderFS.Sdk.ViewModels.Controls.Components; +using SecureFolderFS.Shared.ComponentModel; namespace SecureFolderFS.Sdk.Services { /// /// A service that interacts with common app-related APIs. /// - public interface IApplicationService + public interface IApplicationService : IUriLauncher { /// /// Gets the value that determines whether the app is operating on a desktop platform. @@ -36,13 +36,6 @@ public interface IApplicationService /// A containing version data. string GetSystemVersion(); - /// - /// Launches a URI from app. This can be a URL, folder path, etc. - /// - /// The URI to launch. - /// A that represents the asynchronous operation. - Task OpenUriAsync(Uri uri); - /// /// Tries to schedule the application for restart. /// diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/ISystemService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/ISystemService.cs index 3b258bf92..fceea5ebc 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/ISystemService.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/ISystemService.cs @@ -22,5 +22,20 @@ public interface ISystemService /// A that cancels this action. /// A that represents the asynchronous operation. Value is the amount of usable storage space in bytes. Task GetAvailableFreeSpaceAsync(IFolder storageRoot, CancellationToken cancellationToken = default); + + /// + /// Determines whether the app is registered to start automatically on system startup. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is true if auto start is enabled; otherwise false. + Task IsAutoStartEnabledAsync(CancellationToken cancellationToken = default); + + /// + /// Registers or unregisters the app to start automatically on system startup. + /// + /// Determines whether to register or unregister the app for auto start. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is true if the registration was updated successfully; otherwise false. + Task TrySetAutoStartAsync(bool isEnabled, CancellationToken cancellationToken = default); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs index 855fd006c..73a2a6d58 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/IVaultManagerService.cs @@ -1,10 +1,10 @@ -using OwlCore.Storage; -using SecureFolderFS.Shared.ComponentModel; -using SecureFolderFS.Shared.Models; -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using OwlCore.Storage; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; namespace SecureFolderFS.Sdk.Services { @@ -13,13 +13,6 @@ public interface IVaultManagerService /// /// Creates new or overwrites an existing vault in the specified . /// - /// - /// To retrieve the decryption key, call the .ToString() method - /// on the returned instance. - /// Since the key returned by this method can be used to decrypt vault contents - /// regardless of whether the vault passkey was changed, it is, by nature, - /// very sensitive and should be disposed of as soon as it is no longer needed. - /// /// The folder where the vault should be created. /// The passkey represented by of representing authentication elements to set for this vault. /// The required options to set for this vault. @@ -50,13 +43,35 @@ public interface IVaultManagerService /// /// The that represents the vault. /// The Base64 encoded recovery key. + /// + /// Invoked with the cryptographic parameters detected for the vault. The restoration proceeds only if it returns true. + /// /// A that cancels this action. /// A that represents the asynchronous operation. Value is that represents the recovery key used to decrypt the vault. - Task RestoreAsync(IFolder vaultFolder, string encodedRecoveryKey, CancellationToken cancellationToken = default); + Task RestoreAsync(IFolder vaultFolder, string encodedRecoveryKey, Func> confirmParametersAsync, CancellationToken cancellationToken = default); // TODO: Consider using IVaultUnlockingModel //Task GetUnlockingModelAsync(IFolder vaultFolder, CancellationToken cancellationToken = default); + /// + /// Creates a new App Platform vault. Generates DEK+MAC internally (no password, no keystore.cfg). + /// Returns the security wrapper and raw key bytes for encryption and upload to the server. + /// + /// The folder where the vault should be created. + /// The required options to set for this vault (must have AppPlatform set). + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is a tuple of (unlockContract, dekKey, macKey). + Task<(IDisposable UnlockContract, IKeyUsage DekKey, IKeyUsage MacKey)> CreateAppPlatformAsync(IFolder vaultFolder, VaultOptions vaultOptions, CancellationToken cancellationToken = default); + + /// + /// Unlocks an App Platform vault using the combined DEK+MAC key from the server. + /// + /// The that represents the vault. + /// The combined DEK‖MAC key (64 bytes) obtained from the server-brokered key chain. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is that represents the unlock contract. + Task UnlockAppPlatformAsync(IFolder vaultFolder, IKeyUsage passkey, CancellationToken cancellationToken = default); + Task ModifyComplementationAsync(IFolder vaultFolder, IDisposable unlockContract, ComplementationCredentials credentials, VaultOptions vaultOptions, CancellationToken cancellationToken = default); /// diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IUserSettings.cs b/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IUserSettings.cs index 09b635c13..2992d7a07 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IUserSettings.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/Settings/IUserSettings.cs @@ -33,6 +33,11 @@ public interface IUserSettings : IPersistable, INotifyPropertyChanged /// bool ContinueOnLastVault { get; set; } + /// + /// Gets or sets the ID of the vault to prompt the user to unlock when the app is started. Only one vault can be set at a time. + /// + string? AutoUnlockVaultId { get; set; } + /// /// Gets or sets the value that determines whether to open the vault root folder when it is unlocked. /// @@ -76,11 +81,6 @@ public interface IUserSettings : IPersistable, INotifyPropertyChanged /// bool IsTelemetryEnabled { get; set; } - /// - /// Gets or sets the value that determines whether to periodically clear or untrack system-wide recently accessed items. - /// - bool DisableRecentAccess { get; set; } - /// /// Gets or sets a value that enables or disables the Device Link listening. /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/AccountItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/AccountItemViewModel.cs new file mode 100644 index 000000000..a71ff21d6 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/AccountItemViewModel.cs @@ -0,0 +1,57 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SecureFolderFS.Sdk.Models; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Controls.Components; + +namespace SecureFolderFS.Sdk.ViewModels.Controls +{ + /// + /// Represents a single account row in the Accounts settings page, independent of a provider. + /// + [Bindable(true)] + public sealed partial class AccountItemViewModel : PickerOptionViewModel + { + private readonly IAccountProvider? _provider; + private readonly ObservableCollection? _parent; + + [ObservableProperty] private string? _Subtitle; + + /// + /// Creates a display-only item (e.g. for a picker). The remove command is a no-op. + /// + public AccountItemViewModel(AccountModel model) + : base(model.Id, model.DisplayName ?? model.Id) + { + Subtitle = model.Subtitle; + Icon = model.Icon; + } + + /// + /// Creates a manageable item backed by and removable from . + /// + public AccountItemViewModel( + AccountModel model, + IAccountProvider provider, + ObservableCollection parent) + : this(model) + { + _provider = provider; + _parent = parent; + } + + [RelayCommand] + private async Task RemoveAsync(CancellationToken cancellationToken) + { + if (_provider is null || _parent is null) + return; + + await _provider.RemoveAccountAsync(Id, cancellationToken); + _parent.Remove(this); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/AuthenticationViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/AuthenticationViewModel.cs index 8a6da6dc1..96f2c3818 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/AuthenticationViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/AuthenticationViewModel.cs @@ -57,7 +57,7 @@ public override void Report(IResult? result) /// /// A that cancels this action. /// A that represents the asynchronous operation. - [RelayCommand] + [RelayCommand(IncludeCancelCommand = true)] protected abstract Task ProvideCredentialsAsync(CancellationToken cancellationToken); /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/IAppPlatformVaultRegistration.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/IAppPlatformVaultRegistration.cs new file mode 100644 index 000000000..5f5839a4c --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/IAppPlatformVaultRegistration.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Shared.ComponentModel; + +namespace SecureFolderFS.Sdk.ViewModels.Controls.Authentication +{ + /// + /// Implemented by creation authentication view models that register the newly created + /// vault key material with an App Platform server. + /// + public interface IAppPlatformVaultRegistration + { + /// + /// Encrypts and uploads the vault key (DEK + MAC) to the App Platform server. + /// + /// The unique identifier of the vault. + /// An optional human-friendly display name for the vault. + /// The raw Data Encryption Key. Caller retains ownership. + /// The raw Message Authentication Code key. Caller retains ownership. + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task RegisterVaultAsync(string vaultId, string? name, IKeyUsage dekKey, IKeyUsage macKey, CancellationToken cancellationToken = default); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/RecoveryRequirementViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/RecoveryRequirementViewModel.cs new file mode 100644 index 000000000..3e4aa98cb --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Authentication/RecoveryRequirementViewModel.cs @@ -0,0 +1,67 @@ +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.EventArguments; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; + +namespace SecureFolderFS.Sdk.ViewModels.Controls.Authentication +{ + /// + /// Represents a view of a restored vault that has no credentials configured, where the recovery + /// key is the only way in and new credentials must be set up before the vault can be used again. + /// + [Inject] + [Bindable(true)] + public sealed partial class RecoveryRequirementViewModel : ReportableViewModel + { + [ObservableProperty] private string? _RecoveryKey; + [ObservableProperty] private string? _ErrorMessage; + + /// + public override event EventHandler? StateChanged; + + public RecoveryRequirementViewModel() + { + ServiceProvider = DI.Default; + Title = "SetCredentials".ToLocalized(); + } + + /// + public override void Report(IResult? result) + { + ErrorMessage = result is { Successful: false } + ? result.GetMessage("UnknownError".ToLocalized()) + : null; + } + + [RelayCommand] + private void SetUpCredentials() + { + if (string.IsNullOrWhiteSpace(RecoveryKey)) + return; + + // The host performs the recovery and reports back through Report(), upon which + // the vault is unlocked and new credentials can be registered + ErrorMessage = null; + StateChanged?.Invoke(this, new RecoveryRequestedEventArgs(RecoveryKey)); + } + + [RelayCommand] + private async Task PasteRecoveryKeyAsync(CancellationToken cancellationToken) + { + try + { + RecoveryKey = await ClipboardService.GetTextAsync(cancellationToken) ?? RecoveryKey; + } + catch (FormatException) { } + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs index 11a36722c..d6932c7aa 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/LoginViewModel.cs @@ -201,6 +201,14 @@ private void ResetLoginState() private async Task InitializeLoginAsync(bool allowPersistedCredentials, CancellationToken cancellationToken) { _vaultOptions = await VaultService.GetVaultOptionsAsync(_vaultFolder, cancellationToken); + + // Offer to set up new credentials instead of failing with an unsupported authentication method + if (Array.IndexOf(_vaultOptions.UnlockProcedure.Methods, Constants.Vault.Authentication.AUTH_RECOVERY_KEY_REQUIREMENT) >= 0) + { + CurrentViewModel = new RecoveryRequirementViewModel(); + return; + } + allowPersistedCredentials &= RequiredAuthenticationMethodIds is null; if (allowPersistedCredentials && !PersistedCredentialsModel.Instance.Credentials.IsEmpty() @@ -294,7 +302,10 @@ private async Task TryUnlockAsync(CancellationToken cancellationToken = de { try { - var unlockContract = await VaultManagerService.UnlockAsync(_vaultFolder, _keySequence, cancellationToken); + var isAppPlatform = _authenticatedMethodIds.Contains("app_platform"); // TODO: Avoid arbitrary authId constants in Sdk + var unlockContract = isAppPlatform + ? await VaultManagerService.UnlockAppPlatformAsync(_vaultFolder, _keySequence, cancellationToken) + : await VaultManagerService.UnlockAsync(_vaultFolder, _keySequence, cancellationToken); _vaultOptions ??= await VaultService.GetVaultOptionsAsync(_vaultFolder, cancellationToken); if (string.IsNullOrWhiteSpace(_vaultOptions.VaultId)) { @@ -375,6 +386,10 @@ private async void CurrentViewModel_StateChanged(object? sender, EventArgs e) { await InitAsync(); } + else if (e is RecoveryRequestedEventArgs recoveryArgs) + { + await RecoverAccessAsync(recoveryArgs.RecoveryKey, CancellationToken.None); + } } private async void CurrentViewModel_CredentialsProvided(object? sender, CredentialsProvidedEventArgs e) @@ -464,6 +479,10 @@ partial void OnCurrentViewModelChanged(ReportableViewModel? oldValue, Reportable newViewModel.CredentialsProvided += CurrentViewModel_CredentialsProvided; ProvideCredentialsCommand = newViewModel.ProvideCredentialsCommand; } + else if (newValue is RecoveryRequirementViewModel recoveryRequirementViewModel) + { + ProvideCredentialsCommand = recoveryRequirementViewModel.SetUpCredentialsCommand; + } else ProvideCredentialsCommand = null; } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/ArchivePreviewerViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/ArchivePreviewerViewModel.cs index 0bfa81111..b3bd46986 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/ArchivePreviewerViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/ArchivePreviewerViewModel.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; -using System.IO.Compression; +using System.Linq; using System.Threading; using System.Threading.Tasks; using ByteSizeLib; @@ -18,7 +18,10 @@ using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.Extensions; +using SharpCompress.Archives; +using SharpCompress.Readers; namespace SecureFolderFS.Sdk.ViewModels.Controls.Previewers { @@ -26,6 +29,9 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Previewers [Bindable(true)] public sealed partial class ArchivePreviewerViewModel : FilePreviewerViewModel { + // Formats readable through SharpCompress (7z and rar are read-only formats) + private static readonly string[] SupportedExtensions = [ ".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".xz" ]; + private readonly FolderViewModel _folderViewModel; private readonly TransferViewModel? _transferViewModel; @@ -46,9 +52,8 @@ public ArchivePreviewerViewModel(IFile file, FolderViewModel folderViewModel, Tr /// public override async Task InitAsync(CancellationToken cancellationToken = default) { - // Only zip is supported via System.IO.Compression var extension = Path.GetExtension(Inner.Name).ToLowerInvariant(); - IsSupported = extension == ".zip"; + IsSupported = SupportedExtensions.Contains(extension); var size = await Inner.GetSizeAsync(cancellationToken); if (size is not null) @@ -64,17 +69,26 @@ private async Task ExtractAsync(CancellationToken cancellationToken) IsProgressing = true; try { + // Protected archives need a password before extraction starts + string? password = null; + if (await IsPasswordProtectedAsync(cancellationToken)) + { + password = await RequestPasswordAsync(); + if (password is null) + return; + } + if (_transferViewModel is not null) { _transferViewModel.TransferType = TransferType.Extract; await _transferViewModel.PerformOperationAsync(async ct => { - await ExtractArchiveAsync(ct); + await ExtractArchiveAsync(password, ct); }, cancellationToken); } else { - await ExtractArchiveAsync(cancellationToken); + await ExtractArchiveAsync(password, cancellationToken); } if (OverlayService.CurrentView is PreviewerOverlayViewModel { PreviewerViewModel: { } viewModel } && viewModel == this) @@ -84,9 +98,10 @@ await _transferViewModel.PerformOperationAsync(async ct => { // User cancelled } - catch (Exception) + catch (Exception ex) { - // Extraction failed - silently handle + if (_transferViewModel is not null) + await _transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } finally { @@ -94,68 +109,133 @@ await _transferViewModel.PerformOperationAsync(async ct => } } - private async Task ExtractArchiveAsync(CancellationToken cancellationToken) + private async Task IsPasswordProtectedAsync(CancellationToken cancellationToken) + { + await using var fileStream = await Inner.OpenReadAsync(cancellationToken); + try + { + using var archive = ArchiveFactory.OpenArchive(fileStream, new ReaderOptions() { LeaveStreamOpen = true }); + return archive.Entries.Any(static entry => entry.IsEncrypted); + } + catch (Exception) + { + // Archives with encrypted headers (e.g. rar, 7z) cannot even be enumerated + // without a password - treat open failures of supported formats as protected + return true; + } + } + + private async Task RequestPasswordAsync() + { + var passwordViewModel = new RenameOverlayViewModel("EnterPassword".ToLocalized()) + { + Message = "Password".ToLocalized() + }; + + var result = await OverlayService.ShowAsync(passwordViewModel); + if (!result.Positive() || string.IsNullOrEmpty(passwordViewModel.NewName)) + return null; + + return passwordViewModel.NewName; + } + + private async Task ExtractArchiveAsync(string? password, CancellationToken cancellationToken) { if (_folderViewModel.Folder is not IModifiableFolder modifiableFolder) return; await using var fileStream = await Inner.OpenReadAsync(cancellationToken); - await using var archive = new ZipArchive(fileStream, ZipArchiveMode.Read); + using var archive = ArchiveFactory.OpenArchive(fileStream, new ReaderOptions() + { + Password = password, + LeaveStreamOpen = true + }); + + // Map each root-level name in the archive to a collision-free name in the destination, + // so extraction never silently merges with or overwrites existing items + var existingNames = new HashSet(_folderViewModel.Items.Select(x => x.Inner.Name), StringComparer.OrdinalIgnoreCase); + var rootNameMap = new Dictionary(StringComparer.OrdinalIgnoreCase); // Track root-level items that have already been added to the UI - var addedRootItems = new HashSet(); + var addedRootItems = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var entry in archive.Entries) { cancellationToken.ThrowIfCancellationRequested(); // Skip directory-only entries - if (string.IsNullOrEmpty(entry.Name)) + if (entry.IsDirectory || string.IsNullOrEmpty(entry.Key)) continue; - // Determine the root-level name for this entry - var topLevelParts = entry.FullName.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); - var isRootFile = topLevelParts.Length == 1; - var rootName = topLevelParts[0]; + var parts = entry.Key.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); - // For entries in subdirectories, create the folder hierarchy - var targetFolder = modifiableFolder; - var directoryPath = Path.GetDirectoryName(entry.FullName); - if (!string.IsNullOrEmpty(directoryPath)) + // Guard against zip-slip: skip entries that could escape the destination folder + if (parts.Length == 0 || Array.Exists(parts, static part => part is ".." or ".")) + continue; + + // Resolve the collision-free root name once per distinct root + var rootName = parts[0]; + if (!rootNameMap.TryGetValue(rootName, out var mappedRootName)) + { + mappedRootName = CollisionHelpers.GetAvailableName(rootName, existingNames); + rootNameMap[rootName] = mappedRootName; + existingNames.Add(mappedRootName); + } + + // Root-level file: create it under the mapped (collision-free) name + if (parts.Length == 1) { - var parts = directoryPath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); - for (var i = 0; i < parts.Length; i++) + var rootFile = await modifiableFolder.CreateFileAsync(mappedRootName, false, cancellationToken); + await CopyEntryAsync(entry, rootFile, cancellationToken); + + if (addedRootItems.Add(mappedRootName)) { - cancellationToken.ThrowIfCancellationRequested(); - var subfolder = await targetFolder.CreateFolderAsync(parts[i], false, cancellationToken); - if (subfolder is not IModifiableFolder modifiableSubfolder) - break; - - // Add the root-level folder to the UI on first encounter - if (i == 0 && addedRootItems.Add(rootName)) - { - _folderViewModel.Items.Insert( - new FolderViewModel(subfolder, _folderViewModel.BrowserViewModel, _folderViewModel), - _folderViewModel.BrowserViewModel.Layouts.GetSorter()); - } - - targetFolder = modifiableSubfolder; + _folderViewModel.Items.Insert( + new FileViewModel(rootFile, _folderViewModel.BrowserViewModel, _folderViewModel), + _folderViewModel.BrowserViewModel.Layouts.GetSorter()); } - } - // Create the file and copy contents - var newFile = await targetFolder.CreateFileAsync(entry.Name, true, cancellationToken); - await using var entryStream = await entry.OpenAsync(cancellationToken); - await using var destinationStream = await newFile.OpenWriteAsync(cancellationToken); - await entryStream.CopyToAsync(destinationStream, cancellationToken); + continue; + } - // Add root-level files to the UI - if (isRootFile && addedRootItems.Add(rootName)) + // Create the folder hierarchy; the root folder uses the mapped name, so the + // subtree is guaranteed fresh and cannot clobber pre-existing content + var targetFolder = modifiableFolder; + var hierarchyCreated = true; + for (var i = 0; i < parts.Length - 1; i++) { - _folderViewModel.Items.Insert( - new FileViewModel(newFile, _folderViewModel.BrowserViewModel, _folderViewModel), - _folderViewModel.BrowserViewModel.Layouts.GetSorter()); + cancellationToken.ThrowIfCancellationRequested(); + var folderName = i == 0 ? mappedRootName : parts[i]; + var subfolder = await targetFolder.CreateFolderAsync(folderName, false, cancellationToken); + if (subfolder is not IModifiableFolder modifiableSubfolder) + { + hierarchyCreated = false; + break; + } + + // Add the root-level folder to the UI on first encounter + if (i == 0 && addedRootItems.Add(mappedRootName)) + { + _folderViewModel.Items.Insert( + new FolderViewModel(subfolder, _folderViewModel.BrowserViewModel, _folderViewModel), + _folderViewModel.BrowserViewModel.Layouts.GetSorter()); + } + + targetFolder = modifiableSubfolder; } + + if (!hierarchyCreated) + continue; + + var newFile = await targetFolder.CreateFileAsync(parts[^1], true, cancellationToken); + await CopyEntryAsync(entry, newFile, cancellationToken); } } + + private static async Task CopyEntryAsync(IArchiveEntry entry, IFile destinationFile, CancellationToken cancellationToken) + { + await using var entryStream = await entry.OpenEntryStreamAsync(cancellationToken); + await using var destinationStream = await destinationFile.OpenWriteAsync(cancellationToken); + await entryStream.CopyToAsync(destinationStream, cancellationToken); + } } -} \ No newline at end of file +} diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/CarouselPreviewerViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/CarouselPreviewerViewModel.cs index 4064131d6..324f58897 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/CarouselPreviewerViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/CarouselPreviewerViewModel.cs @@ -66,6 +66,30 @@ partial void OnCurrentIndexChanged(int value) Title = item.Title; } + /// + /// Removes and disposes the given , adjusting + /// so it keeps pointing at a valid slide. + /// + /// The slide to remove. + public void RemoveSlide(BasePreviewerViewModel slide) + { + var index = Slides.IndexOf(slide); + if (index < 0) + return; + + Slides.RemoveAt(index); + (slide as IDisposable)?.Dispose(); + + if (Slides.Count == 0) + return; + + var newIndex = index < CurrentIndex ? CurrentIndex - 1 : Math.Min(CurrentIndex, Slides.Count - 1); + if (newIndex != CurrentIndex) + CurrentIndex = newIndex; + else + Title = Slides.ElementAtOrDefault(CurrentIndex)?.Title; + } + /// public void Dispose() { diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/TextPreviewerViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/TextPreviewerViewModel.cs index f8cf9e6a7..fee88d206 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/TextPreviewerViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Previewers/TextPreviewerViewModel.cs @@ -1,8 +1,10 @@ +using System; using System.ComponentModel; using System.Text; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using OwlCore.Storage; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Storage.Extensions; @@ -35,11 +37,15 @@ public TextPreviewerViewModel(IFile file, bool isReadOnly) public override async Task InitAsync(CancellationToken cancellationToken = default) { IsProgressing = true; - await Task.Delay(100); - - _persistedText = await Inner.ReadAllTextAsync(Encoding.UTF8, cancellationToken); - Text = _persistedText; - IsProgressing = false; + try + { + _persistedText = await Inner.ReadAllTextAsync(Encoding.UTF8, cancellationToken); + Text = _persistedText; + } + finally + { + IsProgressing = false; + } } /// @@ -51,15 +57,36 @@ public async Task SaveAsync(CancellationToken cancellationToken = default) if (Text is null) return; - //await Task.Delay(5000, cancellationToken); await Inner.WriteTextAsync(Text, cancellationToken); _persistedText = Text; WasModified = false; } + [RelayCommand] + private async Task SaveDocumentAsync(CancellationToken cancellationToken) + { + if (IsProgressing) + return; + + IsProgressing = true; + try + { + await SaveAsync(cancellationToken); + } + catch (Exception) + { + // WasModified stays true, so the modified indicator keeps signaling the unsaved state + } + finally + { + IsProgressing = false; + } + } + partial void OnTextChanged(string? value) { - WasModified = value != _persistedText; + // Compare lengths first to avoid a full string comparison on every keystroke + WasModified = value?.Length != _persistedText?.Length || value != _persistedText; CharacterCount = value?.Length ?? 0L; } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/RegisterViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/RegisterViewModel.cs index d9c881287..ad714eeff 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/RegisterViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/RegisterViewModel.cs @@ -18,6 +18,7 @@ public sealed partial class RegisterViewModel : ObservableObject, IDisposable { private readonly AuthenticationStage _authenticationStage; private bool _credentialsAdded; + private bool _committed; [ObservableProperty] private bool _CanContinue; [ObservableProperty] private AuthenticationViewModel? _CurrentViewModel; @@ -38,11 +39,21 @@ public RegisterViewModel(AuthenticationStage authenticationStage, KeySequence? c Credentials = credentials ?? new(); } + /// + /// Marks the credentials as committed to the vault, preventing any subsequent revocation + /// from deleting the newly-enrolled authenticator the vault now depends on. + /// + public void MarkCommitted() + { + _committed = true; + } + public async Task RevokeCredentialsAsync(CancellationToken cancellationToken) { try { - if (!_credentialsAdded) + // Never revoke once the change has been written: the vault now depends on these credentials. + if (_committed || !_credentialsAdded) return; if (CurrentViewModel is null) @@ -88,11 +99,16 @@ async partial void OnCurrentViewModelChanged(AuthenticationViewModel? oldValue, oldValue.StateChanged -= CurrentViewModel_StateChanged; oldValue.CredentialsProvided -= CurrentViewModel_CredentialsProvided; - // We also need to revoke existing credentials if the user added and aborted - if (Credentials.Count > 0) + // Only revoke when the user actually enrolled a credential in this view model. Gating on + // Credentials.Count would also fire for a pre-seeded first-stage key, destroying a live + // authenticator (e.g. deleting windows_hello.cfg) merely by browsing the method list. + if (_credentialsAdded && !_committed) await SafetyHelpers.NoFailureAsync(async () => await oldValue.RevokeAsync(null)); } + // The new view model has no enrolled credentials yet + _credentialsAdded = false; + if (newValue is not null) { newValue.StateChanged += CurrentViewModel_StateChanged; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs index 47190409c..80daa45a7 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs @@ -82,10 +82,10 @@ protected virtual async Task OpenInExternalAppAsync(CancellationToken cancellati { // Cancellation, nothing to report } - catch (Exception) + catch (Exception ex) { if (BrowserViewModel.TransferViewModel is { } transferViewModel) - await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + await transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } } @@ -136,6 +136,7 @@ protected virtual async Task MoveAsync(CancellationToken cancellationToken) 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(items.Select(x => (IStorableChild)x.Inner), async (item, reporter, token) => { // Check if the item source is the same as destination @@ -143,10 +144,11 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner) return; // 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); // Move var movedItem = await destinationFolder.MoveStorableFromAsync(item, modifiableParent, false, availableName, reporter, token); + existingNames.Add(availableName); // Remove existing from folder ParentFolder.Items.RemoveMatch(x => x.Inner.Id == item.Id)?.Dispose(); @@ -164,9 +166,9 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner) { // Cancellation, nothing to report } - catch (Exception) + catch (Exception ex) { - await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + await transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } finally { @@ -212,13 +214,15 @@ protected virtual async Task CopyAsync(CancellationToken cancellationToken) 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(items.Select(x => x.Inner), async (item, reporter, token) => { // 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); // Copy var copiedItem = await modifiableDestination.CreateCopyOfStorableAsync(item, false, availableName, reporter, token); + existingNames.Add(availableName); // Add to destination destinationViewModel.Items.Insert(copiedItem switch @@ -233,9 +237,9 @@ await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, r { // Cancellation, nothing to report } - catch (Exception) + catch (Exception ex) { - await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + await transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } finally { @@ -290,10 +294,10 @@ await OverlayService.ShowAsync(new MessageOverlayViewModel() { // Cancellation, nothing to report } - catch (Exception) + catch (Exception ex) { if (BrowserViewModel.TransferViewModel is { } transferViewModel) - await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + await transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } } @@ -420,9 +424,9 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner) { // Cancellation, nothing to report } - catch (Exception) + catch (Exception ex) { - await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + await transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } finally { @@ -469,9 +473,9 @@ await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, r { // Cancellation, nothing to report } - catch (Exception) + catch (Exception ex) { - await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + await transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } finally { @@ -489,7 +493,7 @@ await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, r /// /// A plain substring check would misclassify sibling paths that share a prefix (e.g. '/a/bc' and '/a/b'). /// - private static bool IsAncestorOrSelf(string destinationId, string itemId) + public static bool IsAncestorOrSelf(string destinationId, string itemId) { if (destinationId.Equals(itemId, StringComparison.OrdinalIgnoreCase)) return true; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs index b315f7a40..b09fe3565 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs @@ -152,7 +152,7 @@ protected override async Task OpenAsync(CancellationToken cancellationToken) Title = "UnsavedChanges".ToLocalized(), Message = "UnsavedChangesDescription".ToLocalized(), PrimaryText = "Save".ToLocalized(), - SecondaryText = "Cancel".ToLocalized() + SecondaryText = "Discard".ToLocalized() }; await Task.Delay(700, CancellationToken.None); @@ -179,10 +179,10 @@ await transferViewModel.PerformOperationAsync(async ct => { // Cancellation, nothing to report } - catch (Exception) + catch (Exception ex) { if (BrowserViewModel.TransferViewModel is { } transferViewModel) - await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + await transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs index 7e1d614a0..c933e49a3 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; @@ -6,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using OwlCore.Storage; +using SecureFolderFS.Sdk.AppModels.Sorters; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Services; @@ -23,6 +25,8 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser [Bindable(true)] public partial class FolderViewModel : BrowserItemViewModel, IViewDesignation { + private const int LISTING_BATCH_SIZE = 32; + private CancellationTokenSource? _listingCts; /// @@ -87,26 +91,66 @@ public async Task ListContentsAsync(CancellationToken cancellationToken = defaul _listingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var token = _listingCts.Token; + // The existing contents are only cleared once the first batch is ready, so a + // canceled or failed enumeration that produced nothing leaves them intact + var cleared = false; + try { var scope = Logger.GetPerformanceScope(); - // Enumerate before mutating the collection, so a canceled or failed - // enumeration leaves the current contents intact var isPickingFolder = BrowserViewModel.IsPickingFolder; - var items = await Folder.GetItemsAsync(isPickingFolder ? StorableType.Folder : StorableType.All, token).ToArrayAsyncImpl(cancellationToken: token); - token.ThrowIfCancellationRequested(); + var sorter = BrowserViewModel.Layouts.GetSorter(); - SelectedItems.Clear(); - Items.DisposeAll(); - Items.Clear(); + // Sorting by date needs the modification date before the item is inserted; + // for other sorters it is loaded lazily when the item scrolls into view + var needsDates = sorter is DateSorter; + + var batch = new List(LISTING_BATCH_SIZE); + await foreach (var item in Folder.GetItemsAsync(isPickingFolder ? StorableType.Folder : StorableType.All, token)) + { + if (isPickingFolder && item is not IFolder) + continue; + + var itemViewModel = (BrowserItemViewModel)(item switch + { + IFile file => new FileViewModel(file, BrowserViewModel, this), + IFolder folder => new FolderViewModel(folder, BrowserViewModel, this), + _ => throw new ArgumentOutOfRangeException(nameof(item)) + }); - BrowserViewModel.Layouts.GetSorter().SortCollection(items.Where(x => !isPickingFolder || x is IFolder).Select(x => (BrowserItemViewModel)(x switch + if (needsDates) + { + itemViewModel.LastModified = item switch + { + IFile file => await file.GetDateModifiedAsync(token), + IFolder folder => await folder.GetDateModifiedAsync(token), + _ => null + }; + } + + batch.Add(itemViewModel); + if (batch.Count < LISTING_BATCH_SIZE) + continue; + + FlushBatch(batch, sorter, ref cleared); + + // Let the UI render the batch and stay responsive even when + // the enumeration completes synchronously (e.g. local storage) + await Task.Yield(); + token.ThrowIfCancellationRequested(); + } + + token.ThrowIfCancellationRequested(); + FlushBatch(batch, sorter, ref cleared); + + // An empty enumeration never flushed - the folder no longer has any items + if (!cleared) { - IFile file => new FileViewModel(file, BrowserViewModel, this), - IFolder folder => new FolderViewModel(folder, BrowserViewModel, this), - _ => throw new ArgumentOutOfRangeException(nameof(x)) - })), Items); + SelectedItems.Clear(); + Items.DisposeAll(); + Items.Clear(); + } // Apply adaptive layout if (SettingsService.UserSettings.IsAdaptiveLayoutEnabled && BrowserViewModel.TransferViewModel is { IsPickingFolder: false }) @@ -120,13 +164,31 @@ public async Task ListContentsAsync(CancellationToken cancellationToken = defaul } catch (Exception ex) { - // Only inform the user that the refresh failed and leave existing contents intact Logger.LogError(ex, "Failed to list the contents of a folder."); if (BrowserViewModel.TransferViewModel is { } transferViewModel) await transferViewModel.ReportErrorAsync("FolderLoadFailed".ToLocalized()); } } + private void FlushBatch(List batch, IItemSorter sorter, ref bool cleared) + { + if (batch.Count == 0) + return; + + if (!cleared) + { + SelectedItems.Clear(); + Items.DisposeAll(); + Items.Clear(); + cleared = true; + } + + foreach (var itemViewModel in batch) + Items.Insert(itemViewModel, sorter); + + batch.Clear(); + } + /// protected override void UpdateStorable(IStorable storable) { @@ -142,8 +204,22 @@ protected override async Task OpenAsync(CancellationToken cancellationToken) await BrowserViewModel.InnerNavigator.NavigateAsync(this); } + /// + public override void Dispose() + { + // Stop an in-flight listing so it does not keep mutating Items after disposal + _listingCts?.TryCancel(); + _listingCts?.Dispose(); + _listingCts = null; + base.Dispose(); + } + private void ApplyAdaptiveLayout() { + // The user picked a layout manually - respect their choice + if (BrowserViewModel.Layouts.IsAdaptiveLayoutSuspended) + return; + var itemCount = Items.Count; if (itemCount == 0) return; @@ -180,29 +256,29 @@ private void ApplyAdaptiveLayout() if (imagePercentage + mediaPercentage >= 90f) { // GalleryView or GridView - BrowserViewModel.Layouts.CurrentViewOption = SettingsService.UserSettings.AreThumbnailsEnabled + BrowserViewModel.Layouts.ApplyAdaptiveViewOption(SettingsService.UserSettings.AreThumbnailsEnabled ? galleryView - : gridView; + : gridView); } else if (imagePercentage + mediaPercentage >= 70f) { // GridView - BrowserViewModel.Layouts.CurrentViewOption = gridView; + BrowserViewModel.Layouts.ApplyAdaptiveViewOption(gridView); } else if (documentPercentage + folderPercentage >= 50f) { // GridView - BrowserViewModel.Layouts.CurrentViewOption = gridView; + BrowserViewModel.Layouts.ApplyAdaptiveViewOption(gridView); } else if (otherPercentage + folderPercentage >= 50f) { // ColumnView - BrowserViewModel.Layouts.CurrentViewOption = columnView; + BrowserViewModel.Layouts.ApplyAdaptiveViewOption(columnView); } else { // ListView - BrowserViewModel.Layouts.CurrentViewOption = listView; + BrowserViewModel.Layouts.ApplyAdaptiveViewOption(listView); } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs index dfb46110e..7a4a13db1 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/SearchBrowserItemViewModel.cs @@ -84,13 +84,15 @@ await _uiContext.PostOrExecuteAsync(() => if (generatedThumbnail is null) return; + // Cache the thumbnail BEFORE handing the stream to the UI (use await) because both the cache copy + // and the image decoder reposition the same stream, so they must not overlap + await _thumbnailCache.CacheThumbnailAsync(cacheKey, generatedThumbnail, cancellationToken).ConfigureAwait(false); + await _uiContext.PostOrExecuteAsync(() => { Thumbnail = generatedThumbnail; return Task.CompletedTask; }); - - _ = _thumbnailCache.CacheThumbnailAsync(cacheKey, generatedThumbnail, cancellationToken); } public bool CanLoadThumbnail() diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/LayoutsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/LayoutsViewModel.cs index 69d12a701..f5f00daad 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/LayoutsViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/LayoutsViewModel.cs @@ -26,12 +26,22 @@ public sealed partial class LayoutsViewModel : ObservableObject, IViewable [ObservableProperty] private bool _IsAscending; [ObservableProperty] private string? _Title; + private bool _isInitialized; + private bool _isAdaptiveChange; + + /// + /// Gets a value indicating whether the user manually picked a view option. + /// Once set, the adaptive layout no longer overrides the user's choice. + /// + public bool IsAdaptiveLayoutSuspended { get; private set; } + public LayoutsViewModel() { SortOptions = new([ new(nameof(NameSorter), "Name".ToLocalized()), new(nameof(KindSorter), "Kind".ToLocalized()), - new(nameof(SizeSorter), "Size".ToLocalized()) + new(nameof(SizeSorter), "Size".ToLocalized()), + new(nameof(DateSorter), "DateModified".ToLocalized()) ]); SizeOptions = new([ new("Small", "SmallSize".ToLocalized()), @@ -50,6 +60,7 @@ public LayoutsViewModel() CurrentSizeOption = SizeOptions[1]; CurrentViewOption = ViewOptions[2]; Title = "ViewOptions".ToLocalized(); + _isInitialized = true; } public IItemSorter GetSorter() @@ -59,12 +70,36 @@ public IItemSorter GetSorter() nameof(NameSorter) => IsAscending ? NameSorter.Ascending : NameSorter.Descending, nameof(KindSorter) => IsAscending ? KindSorter.Ascending : KindSorter.Descending, nameof(SizeSorter) => IsAscending ? SizeSorter.Ascending : SizeSorter.Descending, - _ => NameSorter.Descending + nameof(DateSorter) => IsAscending ? DateSorter.Ascending : DateSorter.Descending, + _ => NameSorter.Ascending }; } + /// + /// Changes on behalf of the adaptive layout, + /// without counting the change as a manual user override. + /// + /// The view option to apply. + public void ApplyAdaptiveViewOption(PickerOptionViewModel? viewOption) + { + _isAdaptiveChange = true; + try + { + CurrentViewOption = viewOption; + } + finally + { + _isAdaptiveChange = false; + } + } + partial void OnCurrentViewOptionChanged(PickerOptionViewModel? value) { + // A view option set after construction that did not come from the adaptive + // layout is a manual user choice - stop the adaptive layout from fighting it + if (_isInitialized && !_isAdaptiveChange) + IsAdaptiveLayoutSuspended = true; + switch (value?.Id) { case nameof(BrowserViewType.ListView): diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs index 00b98dd98..d6440ce02 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs @@ -19,23 +19,35 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Transfer [Bindable(true)] public sealed partial class TransferViewModel : ObservableObject, IViewable, IProgress, IFolderPicker { + public const int HIDE_ANIMATION_DURATION_MS = 350; private const int ERROR_DISPLAY_DURATION_MS = 5000; + private const int MAX_ERROR_SUMMARY_LENGTH = 32; - private readonly BrowserViewModel _browserViewModel; + private readonly BrowserViewModel? _browserViewModel; private readonly SynchronizationContext? _synchronizationContext; private TaskCompletionSource? _tcs; private CancellationTokenSource? _cts; private CancellationTokenSource? _errorCts; [ObservableProperty] private string? _Title; + [ObservableProperty] private string? _ErrorDetails; [ObservableProperty] private bool _CanCancel; [ObservableProperty] private bool _IsVisible; + [ObservableProperty] private bool _IsSuccess; [ObservableProperty] private bool _IsProgressing; [ObservableProperty] private bool _IsPickingFolder; [ObservableProperty] private bool _IsErrorVisible; [ObservableProperty] private TransferType _TransferType; - public TransferViewModel(BrowserViewModel browserViewModel) + /// + /// Gets or sets an optional override for resolving the destination folder when the user + /// confirms a folder pick. Used by hosts that share one transfer view model across multiple + /// browser instances (e.g. tabbed browsing), where the destination is whichever browser is + /// currently in view rather than the one this view model was created with. + /// + public Func? DestinationResolver { get; set; } + + public TransferViewModel(BrowserViewModel? browserViewModel = null) { _browserViewModel = browserViewModel; _synchronizationContext = SynchronizationContext.Current; @@ -68,6 +80,7 @@ public void ShowIndeterminate(string title) { ClearError(); Title = title; + IsSuccess = false; IsProgressing = true; IsVisible = true; } @@ -91,7 +104,12 @@ public async Task ReportErrorAsync(string message) if (token.IsCancellationRequested) return; - Title = message; + ErrorDetails = message; + Title = message.Length > MAX_ERROR_SUMMARY_LENGTH + ? string.Concat(message[..MAX_ERROR_SUMMARY_LENGTH].TrimEnd(), "…") + : message; + + IsSuccess = false; IsErrorVisible = true; IsProgressing = false; CanCancel = true; @@ -100,18 +118,51 @@ public async Task ReportErrorAsync(string message) _ = DismissErrorLaterAsync(token); } + /// + /// Suspends the automatic dismissal of the error banner, keeping it up until the user dismisses it. + /// Used by hosts that let the user open the full . + /// + [RelayCommand] + private void HoldError() + { + // The error stays in place, and only the pending auto-dismissal is canceled + _errorCts?.TryCancel(); + } + private async Task DismissErrorLaterAsync(CancellationToken token) { try { await Task.Delay(ERROR_DISPLAY_DURATION_MS, token); - IsErrorVisible = false; - await this.HideAsync(); } catch (OperationCanceledException) { // A newer operation or an explicit dismissal took over the control + return; } + + await HideErrorAsync(); + } + + /// + /// Hides the error banner and only drops the error state once it has animated out. + /// Clearing it any earlier would repaint the control as a regular operation on the way out. + /// + private async Task HideErrorAsync() + { + var errorCts = _errorCts; + IsVisible = false; + + // A frame of slack, so the error state outlives the hide animation itself + await Task.Delay(HIDE_ANIMATION_DURATION_MS + 50, CancellationToken.None); + + // A newer error may have claimed the control while this one was animating out + if (!ReferenceEquals(errorCts, _errorCts)) + return; + + ClearError(); + IsProgressing = false; + IsSuccess = false; } /// @@ -133,9 +184,11 @@ private void ReportCore(TotalProgress value) if (value.Achieved >= value.Total && value.Total > 0) { Title = "TransferDone".ToLocalized(); + IsSuccess = true; return; } + IsSuccess = false; Title = TransferType switch { TransferType.Copy => "CopyingItemsPlural".ToLocalized(GetInterpolation()), @@ -178,6 +231,7 @@ public CancellationTokenSource GetCancellation(CancellationToken? linkToken = nu TransferType = transferOptions.TransferType; Title = "ChooseDestinationFolder".ToLocalized(); + IsSuccess = false; IsProgressing = false; IsVisible = true; @@ -199,7 +253,9 @@ public CancellationTokenSource GetCancellation(CancellationToken? linkToken = nu private void Confirm() { // Only used for confirming the destination folder - _tcs?.TrySetResult(_browserViewModel.CurrentFolder?.Folder); + _tcs?.TrySetResult(DestinationResolver is not null + ? DestinationResolver() + : _browserViewModel?.CurrentFolder?.Folder); } [RelayCommand] @@ -207,9 +263,10 @@ private async Task CancelAsync() { if (IsErrorVisible) { - // Dismiss the error banner since there is no operation left to cancel - ClearError(); - await this.HideAsync(); + // Dismiss the error banner since there is no operation left to cancel. + // Cancelling in place keeps the same source alive, so the hide below still owns the banner + _errorCts?.TryCancel(); + await HideErrorAsync(); return; } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs index cdc9ebcf9..0a78cfbf0 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs @@ -26,9 +26,9 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.VaultList { - [Inject, Inject, Inject, Inject, Inject] + [Inject, Inject, Inject, Inject, Inject, Inject] [Bindable(true)] - public sealed partial class VaultListItemViewModel : ObservableObject, IAsyncInitialize + public sealed partial class VaultListItemViewModel : ObservableObject, IAsyncInitialize, IDisposable { private readonly IVaultCollectionModel _vaultCollectionModel; @@ -48,6 +48,32 @@ public VaultListItemViewModel(VaultViewModel vaultViewModel, IVaultCollectionMod _vaultCollectionModel = vaultCollectionModel; UpdateCanMove(); + SettingsService.UserSettings.PropertyChanged += UserSettings_PropertyChanged; + } + + public bool IsAutoUnlockEnabled + { + get + { + var persistableId = VaultViewModel.VaultModel.DataModel.PersistableId; + return persistableId is not null && persistableId.Equals(SettingsService.UserSettings.AutoUnlockVaultId); + } + set + { + var persistableId = VaultViewModel.VaultModel.DataModel.PersistableId; + if (persistableId is null) + return; + + if (value) + { + // Marking this vault automatically unmarks the previously chosen one, since only one vault can participate at a time + SettingsService.UserSettings.AutoUnlockVaultId = persistableId; + } + else if (persistableId.Equals(SettingsService.UserSettings.AutoUnlockVaultId)) + SettingsService.UserSettings.AutoUnlockVaultId = null; + + _ = SettingsService.UserSettings.TrySaveAsync(); + } } /// @@ -152,6 +178,10 @@ private async Task RenameAsync(string? newName, CancellationToken cancellationTo [RelayCommand] private async Task RemoveVaultAsync(CancellationToken cancellationToken) { + // Unmark the vault from auto unlock when it is removed from the list + if (IsAutoUnlockEnabled) + IsAutoUnlockEnabled = false; + CustomIcon?.Dispose(); _vaultCollectionModel.Remove(VaultViewModel.VaultModel); if (VaultViewModel.VaultModel.VaultFolder is IBookmark bookmark) @@ -204,5 +234,18 @@ private async Task UpdateIconAsync(CancellationToken cancellationToken) CustomIcon = await MediaService.ReadImageFileAsync(imageFile, cancellationToken); } + + private void UserSettings_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // Update the check state of every item when another vault is chosen for auto unlock + if (e.PropertyName == nameof(SettingsService.UserSettings.AutoUnlockVaultId)) + OnPropertyChanged(nameof(IsAutoUnlockEnabled)); + } + + /// + public void Dispose() + { + SettingsService.UserSettings.PropertyChanged -= UserSettings_PropertyChanged; + } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs index c5009992b..6af687bb0 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListViewModel.cs @@ -153,7 +153,10 @@ private void RemoveVault(IVaultModel vaultModel) try { if (Items.Remove(itemToRemove)) + { + itemToRemove.Dispose(); itemToRemove.VaultViewModel.Dispose(); + } } catch (Exception) { diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs index f105d87cc..a4e92955b 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs @@ -17,6 +17,7 @@ using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Shared.Models; using SecureFolderFS.Shared.SecureStore; @@ -138,6 +139,12 @@ private async Task ChangeCredentialsAsync(IKeyUsage key, VaultOptions configured else await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, UnlockContract, key, updatedOptions, cancellationToken); + // The change has been written. From here on the vault depends on the newly-enrolled credentials, + // so any failure must not bubble out of confirmation (which would keep the dialog open and let a + // subsequent "Back" revoke the credential the vault now requires), and the credentials must be + // locked against revocation. + RegisterViewModel.MarkCommitted(); + if (!string.IsNullOrEmpty(configuredOptions.VaultId)) PersistedCredentialsModel.Instance.Remove(configuredOptions.VaultId); @@ -147,7 +154,7 @@ private async Task ChangeCredentialsAsync(IKeyUsage key, VaultOptions configured if (RegisterViewModel.CurrentViewModel is not null && ConfiguredViewModel is not null && !RegisterViewModel.CurrentViewModel.Id.Equals(ConfiguredViewModel.Id)) - await ConfiguredViewModel.RevokeAsync(configuredOptions.VaultId, cancellationToken); + await SafetyHelpers.NoFailureAsync(async () => await ConfiguredViewModel.RevokeAsync(configuredOptions.VaultId, cancellationToken)); } private ComplementationCredentials CreateComplementationCredentials(IKeyUsage key, AuthenticationMethod configuredProcedure, AuthenticationMethod updatedProcedure) @@ -169,8 +176,8 @@ private ComplementationCredentials CreateComplementationCredentials(IKeyUsage ke { CurrentKeystoreCredential = OldPasskey, CurrentPrimaryCredential = currentPrimaryCredential, - NewPrimaryCredential = primaryChanged ? GetCredentialAt(key, 0) ?? key : null, - NewComplementCredential = GetCredentialAt(key, 1) ?? key + NewPrimaryCredential = primaryChanged ? RequireCredentialAt(key, 0) : null, + NewComplementCredential = RequireCredentialAt(key, 1) }; } @@ -193,8 +200,8 @@ private ComplementationCredentials CreateComplementationCredentials(IKeyUsage ke { CurrentPrimaryCredential = currentPrimaryCredential, CurrentComplementCredential = currentComplementCredential, - NewPrimaryCredential = updatePrimaryCredential ? GetCredentialAt(key, 0) ?? key : null, - NewComplementCredential = updateComplementCredential ? GetCredentialAt(key, 1) ?? key : null + NewPrimaryCredential = updatePrimaryCredential ? RequireCredentialAt(key, 0) : null, + NewComplementCredential = updateComplementCredential ? RequireCredentialAt(key, 1) : null }; } @@ -275,6 +282,15 @@ private static SecureKey CreateStandalonePasskey(IReadOnlyCollection : index == 0 ? key : null; } + // The provided credential material drives crypto decisions (e.g. which key wraps the complement + // secret). Falling back to the whole key sequence when an expected stage is missing would silently + // wrap under the wrong material, so demand the exact credential and fail loudly instead. + private static IKeyUsage RequireCredentialAt(IKeyUsage key, int index) + { + return GetCredentialAt(key, index) + ?? throw new InvalidOperationException($"Credential material for authentication stage {index} is missing."); + } + private IKeyUsage? GetOldCredentialByMethod(AuthenticationMethod configuredProcedure, string authenticationMethodId) { var methodIds = GetOldAuthenticationMethodIds(configuredProcedure); @@ -307,7 +323,16 @@ private static string GetPrimaryMethod(AuthenticationMethod authenticationMethod private void RegisterViewModel_CredentialsProvided(object? sender, CredentialsProvidedEventArgs e) { - _credentialsTcs.TrySetResult(e.Authentication); + try + { + _credentialsTcs.TrySetResult(e.Authentication); + } + finally + { + // Release RegisterViewModel.ConfirmCredentialsAsync, which awaits this completion source; + // otherwise that task would hang forever, leaking on every confirmation. + e.TaskCompletion?.TrySetResult(); + } } /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs index 50ebfd34b..88c8604a2 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs @@ -87,7 +87,15 @@ public async Task ConfirmAsync(CancellationToken cancellationToken) private void RegisterViewModel_CredentialsProvided(object? sender, CredentialsProvidedEventArgs e) { - _credentialsTcs.TrySetResult(e.Authentication); + try + { + _credentialsTcs.TrySetResult(e.Authentication); + } + finally + { + // Release RegisterViewModel.ConfirmCredentialsAsync, which awaits this completion source. + e.TaskCompletion?.TrySetResult(); + } } /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsSelectionViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsSelectionViewModel.cs index 44071aa53..9bee8d5ba 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsSelectionViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsSelectionViewModel.cs @@ -102,7 +102,10 @@ private async Task ItemSelected(AuthenticationViewModel? authenticationViewModel ConfirmationRequested?.Invoke(this, new(_vaultFolder, RegisterViewModel, _authenticationStage) { IsRemoving = false, - IsComplementationAvailable = RegisterViewModel.CurrentViewModel?.CanComplement ?? false, + // Complementation only applies to a proceeding (second) stage. Offering the toggle for a + // first-stage change would let the user enable an option whose confirmation path throws. + IsComplementationAvailable = _authenticationStage == AuthenticationStage.ProceedingStageOnly + && (RegisterViewModel.CurrentViewModel?.CanComplement ?? false), UnlockContract = UnlockContract, OldPasskey = OldPasskey, OldAuthenticationMethodIds = OldAuthenticationMethodIds, diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/BrowserSearchOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/BrowserSearchOverlayViewModel.cs index af81562de..4271f9054 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/BrowserSearchOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/BrowserSearchOverlayViewModel.cs @@ -223,8 +223,10 @@ private void CancelSearch() /// public void Dispose() { + // May be called by both the overlay host and the opener - keep it idempotent CancelSearch(); SearchResults.DisposeAll(); + SearchResults.Clear(); } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs index a81d396c9..4eeb06de0 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs @@ -27,6 +27,7 @@ public sealed partial class CredentialsOverlayViewModel : OverlayViewModel, IAsy private readonly KeySequence _loginKeySequence; private readonly KeySequence _registerKeySequence; private readonly AuthenticationStage _authenticationStage; + private readonly IDisposable? _unlockContract; private string? _primaryAuthenticationMethodId; [ObservableProperty] private LoginViewModel _LoginViewModel; @@ -35,13 +36,14 @@ public sealed partial class CredentialsOverlayViewModel : OverlayViewModel, IAsy [ObservableProperty] private INotifyPropertyChanged? _SelectedViewModel; [ObservableProperty] private InfoBarViewModel _StatusInfoBar = new(); - public CredentialsOverlayViewModel(IFolder vaultFolder, string? vaultName, AuthenticationStage authenticationStage) + public CredentialsOverlayViewModel(IFolder vaultFolder, string? vaultName, AuthenticationStage authenticationStage, IDisposable? unlockContract = null) { ServiceProvider = DI.Default; _loginKeySequence = new(); _registerKeySequence = new(); _vaultFolder = vaultFolder; _authenticationStage = authenticationStage; + _unlockContract = unlockContract; RegisterViewModel = new(authenticationStage, _registerKeySequence); LoginViewModel = new(vaultFolder, LoginViewType.Basic, _loginKeySequence) { Title = vaultName }; @@ -81,6 +83,14 @@ private void LoginViewModel_StateChanged(object? sender, EventArgs e) /// public async Task InitAsync(CancellationToken cancellationToken = default) { + // The vault is already unlocked, so there is nothing to authenticate against + // and the credentials can be registered right away + if (_unlockContract is not null) + { + BeginCredentialsReset(_unlockContract); + return; + } + try { var vaultOptions = await VaultService.GetVaultOptionsAsync(_vaultFolder, cancellationToken); @@ -110,14 +120,7 @@ private void LoginViewModel_VaultUnlocked(object? sender, VaultUnlockedEventArgs { if (e.IsRecovered) { - Title = "SetCredentials".ToLocalized(); - PrimaryText = "Confirm".ToLocalized(); - CanContinue = false; - - // Note: We can omit the fact that a flag other than FirstStage is passed to the ResetViewModel (via RegisterViewModel). - // The flag is manipulating the order at which keys are placed in the key sequence, so it shouldn't matter if it's cleared here - _loginKeySequence.Dispose(); - SelectedViewModel = new CredentialsResetViewModel(_vaultFolder, e.UnlockContract, RegisterViewModel).WithInitAsync(); + BeginCredentialsReset(e.UnlockContract); } else { @@ -141,6 +144,22 @@ private void LoginViewModel_VaultUnlocked(object? sender, VaultUnlockedEventArgs } } + /// + /// Moves the overlay to the stage where new credentials are registered for an unlocked vault. + /// + /// The contract of the unlocked vault under which the credentials are re-keyed. + private void BeginCredentialsReset(IDisposable unlockContract) + { + Title = "SetCredentials".ToLocalized(); + PrimaryText = "Confirm".ToLocalized(); + CanContinue = false; + + // Note: We can omit the fact that a flag other than FirstStage is passed to the ResetViewModel (via RegisterViewModel). + // The flag is manipulating the order at which keys are placed in the key sequence, so it shouldn't matter if it's cleared here + _loginKeySequence.Dispose(); + SelectedViewModel = new CredentialsResetViewModel(_vaultFolder, unlockContract, RegisterViewModel).WithInitAsync(); + } + private void SelectionViewModel_ConfirmationRequested(object? sender, CredentialsConfirmationViewModel e) { CanContinue = e.IsRemoving || (SelectionViewModel.RegisterViewModel?.CanContinue ?? false); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkCredentialsOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkCredentialsOverlayViewModel.cs index 6561653d7..ce864d5c8 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkCredentialsOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkCredentialsOverlayViewModel.cs @@ -132,6 +132,9 @@ private void OnEnableDeviceLinkChanged(bool newValue) private async Task StartListeningAsync() { + // Tear down any previous instance so listeners and sockets are never leaked + StopListening(); + _deviceLinkService = new DeviceLinkService( Environment.MachineName, "SecureFolderFS Phone", diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkRequestOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkRequestOverlayViewModel.cs index 72f30c976..63181ca12 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkRequestOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceLinkRequestOverlayViewModel.cs @@ -28,7 +28,8 @@ public DeviceLinkRequestOverlayViewModel(AuthenticationRequestViewModel requestV ServiceProvider = DI.Default; _requestViewModel = requestViewModel; RemoteDeviceName = requestViewModel.DesktopName; - CredentialName = "CredentialRequested".ToLocalized($"{requestViewModel.CredentialName} ({requestViewModel.CredentialId?.Substring(0, 8)})"); + var shortCredentialId = requestViewModel.CredentialId is { Length: > 8 } longId ? longId[..8] : requestViewModel.CredentialId; + CredentialName = "CredentialRequested".ToLocalized($"{requestViewModel.CredentialName} ({shortCredentialId})"); } /// diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceSetupOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceSetupOverlayViewModel.cs new file mode 100644 index 000000000..39e119e4d --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/DeviceSetupOverlayViewModel.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays +{ + /// + /// Overlay view model for the App Platform device setup dialog. + /// Prompts the user for their Account Key passphrase to bootstrap a new device. + /// + [Bindable(true)] + public sealed partial class DeviceSetupOverlayViewModel : OverlayViewModel + { + [ObservableProperty] private string? _Passphrase; + [ObservableProperty] private string? _ErrorMessage; + + /// + /// Set to true when the user requests an account key reset (forgot passphrase flow) + /// instead of providing a passphrase. The caller should handle the reset API call. + /// + public bool ResetRequested { get; set; } + + public DeviceSetupOverlayViewModel() + { + Title = "Device Setup Required"; + PrimaryText = "Continue"; + CanContinue = true; + CanCancel = true; + } + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PreviewerOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PreviewerOverlayViewModel.cs index 7ef8875fb..ad3102aca 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PreviewerOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PreviewerOverlayViewModel.cs @@ -15,6 +15,7 @@ using SecureFolderFS.Shared.Enums; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; +using SecureFolderFS.Storage.Extensions; namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays { @@ -22,12 +23,25 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays [Inject, Inject] public sealed partial class PreviewerOverlayViewModel : OverlayViewModel, IAsyncInitialize, IDisposable { + // Text files are loaded into memory in full; refuse to preview unreasonably large ones + private const long MAX_TEXT_PREVIEW_SIZE = 10 * 1024 * 1024; + private readonly BrowserItemViewModel _itemViewModel; private readonly FolderViewModel _folderViewModel; [ObservableProperty] private bool _IsImmersed; [ObservableProperty] private BasePreviewerViewModel? _PreviewerViewModel; + /// + /// Occurs when the previewer requests to be closed, e.g. after the previewed item was deleted. + /// + public event EventHandler? CloseRequested; + + /// + /// Gets the command that deletes the currently previewed item, or null when the vault is read-only. + /// + public IAsyncRelayCommand? DeleteItemCommand => _folderViewModel.BrowserViewModel.Options.IsReadOnly ? null : DeleteCurrentCommand; + public PreviewerOverlayViewModel(BrowserItemViewModel itemViewModel, FolderViewModel folderViewModel) { ServiceProvider = DI.Default; @@ -36,15 +50,16 @@ public PreviewerOverlayViewModel(BrowserItemViewModel itemViewModel, FolderViewM } /// - public Task InitAsync(CancellationToken cancellationToken = default) + public async Task InitAsync(CancellationToken cancellationToken = default) { if (_itemViewModel.Inner is not IFile file) - return Task.CompletedTask; + return; var classification = FileTypeHelper.GetClassification(_itemViewModel.Inner); var previewer = (BasePreviewerViewModel)(classification.TypeHint switch { - TypeHint.Plaintext => new TextPreviewerViewModel(file, _folderViewModel.BrowserViewModel.Options.IsReadOnly).WithInitAsync(cancellationToken), + TypeHint.Plaintext when await IsWithinTextSizeLimitAsync(file, cancellationToken) + => new TextPreviewerViewModel(file, _folderViewModel.BrowserViewModel.Options.IsReadOnly).WithInitAsync(cancellationToken), TypeHint.Document when classification is { MimeType: "application/pdf" } => new PdfPreviewerViewModel(file).WithInitAsync(cancellationToken), TypeHint.Image or TypeHint.Media or TypeHint.Audio => new CarouselPreviewerViewModel( _folderViewModel.Items @@ -57,8 +72,24 @@ public Task InitAsync(CancellationToken cancellationToken = default) (PreviewerViewModel as IDisposable)?.Dispose(); PreviewerViewModel = previewer; + } - return Task.CompletedTask; + private static async Task IsWithinTextSizeLimitAsync(IFile file, CancellationToken cancellationToken) + { + try + { + var size = await file.GetSizeAsync(cancellationToken); + return size is null or <= MAX_TEXT_PREVIEW_SIZE; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + // If the size cannot be determined, attempt the preview anyway + return true; + } } [RelayCommand] @@ -96,6 +127,38 @@ private async Task ShareAsync() await ShareService.ShareFileAsync(filePreviewer.Inner); } + [RelayCommand] + private async Task DeleteCurrentAsync(CancellationToken cancellationToken) + { + BasePreviewerViewModel? previewer = PreviewerViewModel as FilePreviewerViewModel; + if (PreviewerViewModel is CarouselPreviewerViewModel carouselPreviewer) + previewer = carouselPreviewer.Slides.ElementAtOrDefault(carouselPreviewer.CurrentIndex); + + if (previewer is not FilePreviewerViewModel filePreviewer) + return; + + // Delegate to the browser item so the recycle bin and confirmation flows apply + var itemViewModel = _folderViewModel.Items.FirstOrDefault(x => x.Inner.Id == filePreviewer.Inner.Id); + if (itemViewModel is null) + return; + + await itemViewModel.DeleteCommand.ExecuteAsync(null); + + // The deletion may have been declined in the confirmation prompt or may have failed + if (_folderViewModel.Items.Any(x => x.Inner.Id == filePreviewer.Inner.Id)) + return; + + if (PreviewerViewModel is CarouselPreviewerViewModel carouselViewModel) + { + carouselViewModel.RemoveSlide(filePreviewer); + if (carouselViewModel.Slides.Count > 0) + return; + } + + // Nothing left to preview + CloseRequested?.Invoke(this, EventArgs.Empty); + } + /// public void Dispose() { diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PropertiesOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PropertiesOverlayViewModel.cs index 0418e2dfc..00e36ec96 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PropertiesOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/PropertiesOverlayViewModel.cs @@ -23,6 +23,7 @@ public sealed partial class PropertiesOverlayViewModel : OverlayViewModel, IWrap { [ObservableProperty] private string? _Id; [ObservableProperty] private string? _SizeText; + [ObservableProperty] private string? _ItemCountText; [ObservableProperty] private string? _CiphertextId; [ObservableProperty] private string? _FileTypeText; [ObservableProperty] private string? _DateCreatedText; @@ -76,6 +77,15 @@ public async Task InitAsync(CancellationToken cancellationToken = default) IFile => Inner.AsWrapper().GetWrapperAt("CryptoFile").Inner.Id, _ => CiphertextId }; + + if (Inner is IFolder innerFolder) + { + var itemCount = 0; + await foreach (var _ in innerFolder.GetItemsAsync(StorableType.All, cancellationToken)) + itemCount++; + + ItemCountText = "ElementsCountPlural".ToLocalized(itemCount); + } } [RelayCommand] diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/VaultRestorationOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/VaultRestorationOverlayViewModel.cs index 926930871..fd4e169ad 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/VaultRestorationOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/VaultRestorationOverlayViewModel.cs @@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; @@ -20,6 +21,10 @@ public sealed partial class VaultRestorationOverlayViewModel : OverlayViewModel, private readonly IFolder _vaultFolder; [ObservableProperty] private string? _RecoveryKey; + [ObservableProperty] private bool _IsAwaitingConfirmation; + [ObservableProperty] private VaultRestorationParameters? _DetectedParameters; + [ObservableProperty] private string? _DetectedFileNameCipher; + [ObservableProperty] private bool _IsFileNameEncryptionMissing; public IDisposable? UnlockContract { get; private set; } @@ -36,7 +41,7 @@ public async Task RestoreAsync(CancellationToken cancellationToken = de try { - UnlockContract = await VaultManagerService.RestoreAsync(_vaultFolder, RecoveryKey, cancellationToken); + UnlockContract = await VaultManagerService.RestoreAsync(_vaultFolder, RecoveryKey, ConfirmParametersAsync, cancellationToken); return Result.Success; } catch (Exception ex) @@ -45,6 +50,31 @@ public async Task RestoreAsync(CancellationToken cancellationToken = de } } + /// + /// Presents the parameters detected for the vault and waits for the user to accept them. + /// + /// + /// The first pass surfaces the parameters and reports back that they are not confirmed, which + /// leaves the dialog open showing them; accepting re-runs the restoration, and the second pass confirms. + /// + private Task ConfirmParametersAsync(VaultRestorationParameters parameters, CancellationToken cancellationToken) + { + if (IsAwaitingConfirmation) + return Task.FromResult(true); + + DetectedParameters = parameters; + + // A vault detected as having no filename encryption must be notified about + IsFileNameEncryptionMissing = !parameters.IsFileNameEncrypted; + DetectedFileNameCipher = parameters.IsFileNameEncrypted + ? $"{parameters.FileNameCipherId} ({parameters.FileNameEncodingId})" + : "NoEncryption".ToLocalized(); + + IsAwaitingConfirmation = true; + + return Task.FromResult(false); + } + [RelayCommand] private async Task PasteRecoveryKeyAsync(CancellationToken cancellationToken) { diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/AccountsSettingsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/AccountsSettingsViewModel.cs new file mode 100644 index 000000000..74939e4a2 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/AccountsSettingsViewModel.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Controls; +using SecureFolderFS.Shared; + +namespace SecureFolderFS.Sdk.ViewModels.Views.Settings +{ + [Bindable(true)] + public sealed class AccountsSettingsViewModel : BaseSettingsViewModel + { + /// + /// Gets the accounts managed on this device, aggregated across all registered providers. + /// + public ObservableCollection Accounts { get; } = new(); + + /// + public override async Task InitAsync(CancellationToken cancellationToken = default) + { + // Account providers are optional and platform-dependent; resolve the collection defensively. + var providers = DI.OptionalService>(); + + // Gather everything before touching the collection, + // so concurrent InitAsync calls can't interleave into duplicates. + var items = new List(); + if (providers is not null) + { + foreach (var provider in providers) + { + foreach (var account in await provider.GetAccountsAsync(cancellationToken)) + items.Add(new AccountItemViewModel(account, provider, Accounts)); + } + } + + Accounts.Clear(); + foreach (var item in items) + Accounts.Add(item); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/GeneralSettingsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/GeneralSettingsViewModel.cs index d5f85298b..6aa6f0ec9 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/GeneralSettingsViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/GeneralSettingsViewModel.cs @@ -1,21 +1,21 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Services; -using SecureFolderFS.Sdk.ViewModels.Controls; using SecureFolderFS.Sdk.ViewModels.Controls.Banners; +using SecureFolderFS.Sdk.ViewModels.Controls.Components; using SecureFolderFS.Shared; using SecureFolderFS.Storage.Pickers; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using SecureFolderFS.Sdk.ViewModels.Controls.Components; namespace SecureFolderFS.Sdk.ViewModels.Views.Settings { @@ -70,7 +70,7 @@ private Task RestartAsync() private async Task ExportSettingsAsync(CancellationToken cancellationToken) { await using var exportStream = await UserSettings.ExportAsync(cancellationToken); - if (exportStream == System.IO.Stream.Null) + if (exportStream == Stream.Null) return; var filter = new Dictionary() diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs index 5a80febdd..ef1e20c2d 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs @@ -1,18 +1,24 @@ -using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels.Controls.Banners; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Helpers; using System.ComponentModel; using System.Threading; using System.Threading.Tasks; namespace SecureFolderFS.Sdk.ViewModels.Views.Settings { + [Inject] [Bindable(true)] - public sealed class PreferencesSettingsViewModel : BaseSettingsViewModel + public sealed partial class PreferencesSettingsViewModel : BaseSettingsViewModel { public FileSystemBannerViewModel BannerViewModel { get; } public PreferencesSettingsViewModel() { + ServiceProvider = DI.Default; BannerViewModel = new(); Title = "SettingsPreferences".ToLocalized(); } @@ -20,7 +26,14 @@ public PreferencesSettingsViewModel() public bool StartOnSystemStartup { get => UserSettings.StartOnSystemStartup; - set => UserSettings.StartOnSystemStartup = value; + set + { + if (UserSettings.StartOnSystemStartup == value) + return; + + UserSettings.StartOnSystemStartup = value; + _ = ApplyAutoStartAsync(value); + } } public bool ReduceToBackground @@ -69,6 +82,25 @@ public bool IsContentCacheEnabled public override async Task InitAsync(CancellationToken cancellationToken = default) { await BannerViewModel.InitAsync(cancellationToken); + + // Reflect auto start changes made outside the app (e.g. in system settings) + var isAutoStartEnabled = await SafetyHelpers.NoFailureAsync(async () => await SystemService.IsAutoStartEnabledAsync(cancellationToken)); + if (UserSettings.StartOnSystemStartup != isAutoStartEnabled) + { + UserSettings.StartOnSystemStartup = isAutoStartEnabled; + OnPropertyChanged(nameof(StartOnSystemStartup)); + } + } + + private async Task ApplyAutoStartAsync(bool isEnabled) + { + var isApplied = await SafetyHelpers.NoFailureAsync(async () => await SystemService.TrySetAutoStartAsync(isEnabled)); + if (isApplied) + return; + + // Revert the setting when the platform registration was unsuccessful + UserSettings.StartOnSystemStartup = !isEnabled; + OnPropertyChanged(nameof(StartOnSystemStartup)); } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs index ed0921ce4..543622107 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input; using OwlCore.Storage; using SecureFolderFS.Sdk.AppModels; +using SecureFolderFS.Sdk.AppModels.Sorters; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Extensions; @@ -20,6 +21,8 @@ using System.Collections.ObjectModel; using System.Collections.Generic; using System.ComponentModel; +using System.IO; +using System.IO.Compression; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -169,6 +172,147 @@ protected virtual void ToggleSelection(bool? value = null) CurrentFolder?.Items.UnselectAll(); } + [RelayCommand] + protected virtual void SelectAll() + { + if (!IsSelecting || CurrentFolder is null) + return; + + CurrentFolder.Items.SelectAll(); + } + + [RelayCommand] + protected virtual Task MoveSelectedAsync(CancellationToken cancellationToken) + { + return ExecuteOnSelectionAsync(static item => item.MoveCommand.ExecuteAsync(null)); + } + + [RelayCommand] + protected virtual Task CopySelectedAsync(CancellationToken cancellationToken) + { + return ExecuteOnSelectionAsync(static item => item.CopyCommand.ExecuteAsync(null)); + } + + [RelayCommand] + protected virtual Task ExportSelectedAsync(CancellationToken cancellationToken) + { + return ExecuteOnSelectionAsync(static item => item.ExportCommand.ExecuteAsync(null)); + } + + [RelayCommand] + protected virtual Task DeleteSelectedAsync(CancellationToken cancellationToken) + { + return ExecuteOnSelectionAsync(static item => item.DeleteCommand.ExecuteAsync(null)); + } + + [RelayCommand] + protected virtual async Task CompressSelectedAsync(CancellationToken cancellationToken) + { + if (Options.IsReadOnly) + return; + + if (CurrentFolder?.Folder is not IModifiableFolder modifiableFolder) + return; + + if (TransferViewModel is not { IsProgressing: false } transferViewModel) + return; + + var items = CurrentFolder.SelectedItems.ToArray(); + if (items.IsEmpty()) + return; + + IsSelecting = false; + + var desiredName = items.Length == 1 + ? $"{Path.GetFileNameWithoutExtension(items[0].Inner.Name)}.zip" + : "Archive.zip"; + var archiveName = CollisionHelpers.GetAvailableName(desiredName, CurrentFolder.Items.Select(x => x.Inner.Name)); + + IFile? archiveFile = null; + try + { + using var cts = transferViewModel.GetCancellation(cancellationToken); + transferViewModel.ShowIndeterminate("Compressing".ToLocalized()); + + archiveFile = await modifiableFolder.CreateFileAsync(archiveName, false, cts.Token); + await using (var archiveStream = await archiveFile.OpenWriteAsync(cts.Token)) + { + // Wrap in a forward-only stream so ZipArchive never seeks back to patch headers. + // Seeking back would force a read-modify-write on the write-only encrypting stream, corrupting already-written data + await using var forwardOnlyStream = new ForwardOnlyWriteStream(archiveStream); + await using (var zipArchive = new ZipArchive(forwardOnlyStream, ZipArchiveMode.Create)) + { + foreach (var item in items) + await AddToArchiveAsync(zipArchive, item.Inner, string.Empty, cts.Token); + } + } + + CurrentFolder.Items.Insert(new FileViewModel(archiveFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + } + catch (OperationCanceledException) + { + await CleanupPartialArchiveAsync(archiveFile); + } + catch (Exception ex) + { + await CleanupPartialArchiveAsync(archiveFile); + await transferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); + } + finally + { + await transferViewModel.HideAsync(); + } + } + + private static async Task AddToArchiveAsync(ZipArchive zipArchive, IStorable storable, string basePath, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + switch (storable) + { + case IFile file: + { + var entry = zipArchive.CreateEntry($"{basePath}{file.Name}", CompressionLevel.Optimal); + await using var entryStream = entry.Open(); + await using var sourceStream = await file.OpenReadAsync(cancellationToken); + await sourceStream.CopyToAsync(entryStream, cancellationToken); + break; + } + + case IFolder folder: + { + await foreach (var item in folder.GetItemsAsync(StorableType.All, cancellationToken)) + await AddToArchiveAsync(zipArchive, item, $"{basePath}{folder.Name}/", cancellationToken); + break; + } + } + } + + private async Task CleanupPartialArchiveAsync(IFile? archiveFile) + { + if (archiveFile is not IStorableChild archiveChild || CurrentFolder?.Folder is not IModifiableFolder modifiableFolder) + return; + + try + { + await modifiableFolder.DeleteAsync(archiveChild, CancellationToken.None); + } + catch (Exception) + { + // The partial archive could not be removed - it will show up on the next refresh + } + } + + private async Task ExecuteOnSelectionAsync(Func action) + { + // The item-level commands already operate on the whole selection + // when IsSelecting is active - delegate to any selected item + var selectedItem = CurrentFolder?.SelectedItems.FirstOrDefault(); + if (selectedItem is null) + return; + + await action(selectedItem); + } + [RelayCommand] protected virtual async Task SearchAsync() { @@ -242,10 +386,18 @@ protected virtual async Task ChangeViewOptionsAsync(CancellationToken cancellati return; var originalSortOption = Layouts.CurrentSortOption; + var originalIsAscending = Layouts.IsAscending; await OverlayService.ShowAsync(Layouts); - if (originalSortOption != Layouts.CurrentSortOption) - Layouts.GetSorter()?.SortCollection(CurrentFolder.Items, CurrentFolder.Items); + if (originalSortOption != Layouts.CurrentSortOption || originalIsAscending != Layouts.IsAscending) + { + // Date sorting needs modification dates, which are loaded lazily and may be + // missing for items that were never scrolled into view - relist to fetch them + if (Layouts.GetSorter() is DateSorter && CurrentFolder.Items.Any(x => x.LastModified is null)) + await CurrentFolder.ListContentsAsync(cancellationToken); + else + Layouts.GetSorter()?.SortCollection(CurrentFolder.Items, CurrentFolder.Items); + } } [RelayCommand] @@ -278,6 +430,12 @@ protected virtual async Task NewItemAsync(string? itemType, CancellationToken ca if (result.Aborted() || newItemViewModel.ItemName is null) return; + // The collision check below runs against the loaded items, so make sure + // the listing is complete - otherwise CreateFileAsync(overwrite: false) + // could silently return an existing item instead of creating a new one + if (CurrentFolder.Items.IsEmpty()) + await CurrentFolder.ListContentsAsync(cancellationToken); + var formattedName = CollisionHelpers.GetAvailableName( FormattingHelpers.SanitizeItemName(newItemViewModel.ItemName, "New item"), CurrentFolder.Items.Select(x => x.Inner.Name)); @@ -304,10 +462,10 @@ protected virtual async Task NewItemAsync(string? itemType, CancellationToken ca { // Cancellation, nothing to report } - catch (Exception) + catch (Exception ex) { if (TransferViewModel is not null) - await TransferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + await TransferViewModel.ReportErrorAsync($"{"OperationFailed".ToLocalized()} ({ex.Message})"); } } @@ -394,13 +552,15 @@ await TransferViewModel.TransferAsync([ folder ], async (item, reporter, token) TransferViewModel.TransferType = TransferType.Copy; using var cts = TransferViewModel.GetCancellation(cancellationToken); + var existingNames = new HashSet(CurrentFolder.Items.Select(x => x.Inner.Name), StringComparer.OrdinalIgnoreCase); await TransferViewModel.TransferAsync(galleryItems, async (item, token) => { // Get available name to avoid collision - var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); + var availableName = CollisionHelpers.GetAvailableName(item.Name, existingNames); // Copy var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); + existingNames.Add(availableName); // Add to destination CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); @@ -414,9 +574,9 @@ await TransferViewModel.TransferAsync(galleryItems, async (item, token) => { // 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/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs index 78b35790a..d31eb7b4d 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Contexts; using SecureFolderFS.Sdk.Enums; @@ -201,6 +202,11 @@ private async Task UnlockAsync(IDisposable unlockContract) // Navigate away NavigationRequested?.Invoke(this, new UnlockNavigationRequestedEventArgs(unlockedVaultViewModel, this)); + // A restored vault is unlocked right away but has no credentials configured, + // so the user is asked to set them up before anything else + if (await RequiresCredentialsSetupAsync()) + await SetUpCredentialsAsync(unlockedVaultViewModel.VaultFolder, unlockContract); + // Show vault tutorial if (SettingsService.AppSettings.ShouldShowVaultTutorial) { @@ -219,6 +225,45 @@ private async Task UnlockAsync(IDisposable unlockContract) } } + /// + /// Determines whether the vault still awaits credentials, which is the case for a vault + /// that was restored and can, for the time being, only be unlocked with its recovery key. + /// + /// A that represents the asynchronous operation. Value is true if credentials need to be set up; otherwise false. + private async Task RequiresCredentialsSetupAsync() + { + if (VaultViewModel.VaultModel.VaultFolder is not { } vaultFolder) + return false; + + try + { + var vaultOptions = await VaultService.GetVaultOptionsAsync(vaultFolder); + return Array.IndexOf(vaultOptions.UnlockProcedure.Methods, Constants.Vault.Authentication.AUTH_RECOVERY_KEY_REQUIREMENT) >= 0; + } + catch (Exception) + { + // The vault is already unlocked at this point, so a failure here must not stand in the way + return false; + } + } + + /// + /// Shows the overlay that registers new credentials for the just unlocked vault. + /// + /// + /// Mirrors changing the first authentication from , except that + /// the unlock contract is already at hand, so the recovery key does not have to be provided a second time. + /// + private async Task SetUpCredentialsAsync(IFolder vaultFolder, IDisposable unlockContract) + { + if (IsReadOnly || OverlayService.CurrentView is not null) + return; + + using var credentialsOverlay = new CredentialsOverlayViewModel(vaultFolder, VaultViewModel.Title, AuthenticationStage.FirstStageOnly, unlockContract); + await credentialsOverlay.InitAsync(); + await OverlayService.ShowAsync(credentialsOverlay); + } + /// public void Report(IResult result) { diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs index 1344902e1..5d9e6f488 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs @@ -26,6 +26,7 @@ public sealed partial class VaultPropertiesViewModel : BaseDesignationViewModel, [ObservableProperty] private string? _FileNameShorteningText; [ObservableProperty] private string? _ActiveFileSystemText; [ObservableProperty] private string? _FileSystemDescriptionText; + [ObservableProperty] private bool _IsAppPlatform; /// public UnlockedVaultViewModel UnlockedVaultViewModel { get; } @@ -59,6 +60,7 @@ public async Task InitAsync(CancellationToken cancellationToken = default) ActiveFileSystemText = UnlockedVaultViewModel.StorageRoot.FileSystemName; FileSystemDescriptionText = UnlockedVaultViewModel.StorageRoot.Options.GetDescription(); SecurityText = await VaultCredentialsService.FromUnlockProcedureAsync(UnlockedVaultViewModel.VaultFolder, vaultOptions.UnlockProcedure, cancellationToken); + IsAppPlatform = vaultOptions.AppPlatform is not null; if (!RecycleBinOverlayViewModel.IsInitialized && (await IapService.IsOwnedAsync(IapProductType.Any, cancellationToken) || await RecycleBinOverlayViewModel.HasItemsAsync(cancellationToken))) @@ -68,10 +70,9 @@ public async Task InitAsync(CancellationToken cancellationToken = default) [RelayCommand] private async Task ChangeFirstAuthenticationAsync(CancellationToken cancellationToken) { - if (UnlockedVaultViewModel.Options.IsReadOnly) - return; - - if (OverlayService.CurrentView is not null) + if (UnlockedVaultViewModel.Options.IsReadOnly + || OverlayService.CurrentView is not null + || IsAppPlatform) return; using var credentialsOverlay = new CredentialsOverlayViewModel(UnlockedVaultViewModel.VaultFolder, VaultViewModel.Title, AuthenticationStage.FirstStageOnly); @@ -83,10 +84,9 @@ private async Task ChangeFirstAuthenticationAsync(CancellationToken cancellation [RelayCommand] private async Task ChangeSecondAuthenticationAsync(CancellationToken cancellationToken) { - if (UnlockedVaultViewModel.Options.IsReadOnly) - return; - - if (OverlayService.CurrentView is not null) + if (UnlockedVaultViewModel.Options.IsReadOnly + || OverlayService.CurrentView is not null + || IsAppPlatform) return; using var credentialsOverlay = new CredentialsOverlayViewModel(UnlockedVaultViewModel.VaultFolder, VaultViewModel.Title, AuthenticationStage.ProceedingStageOnly); @@ -98,7 +98,7 @@ private async Task ChangeSecondAuthenticationAsync(CancellationToken cancellatio [RelayCommand] private async Task ViewRecoveryAsync(CancellationToken cancellationToken) { - if (OverlayService.CurrentView is not null) + if (OverlayService.CurrentView is not null || IsAppPlatform) return; using var previewRecoveryOverlay = new PreviewRecoveryOverlayViewModel(UnlockedVaultViewModel.VaultFolder, VaultViewModel.Title); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs index dfe906ae9..bcf4f0739 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs @@ -96,6 +96,25 @@ public async Task TryContinueAsync(CancellationToken cancellationToken) if (RegisterViewModel.CurrentViewModel is IVaultOptionsProvider optionsProvider) vaultOptions = optionsProvider.AmendVaultOptions(vaultOptions); + // App Platform vaults generate their own key material and register it with the server + if (RegisterViewModel.CurrentViewModel is IAppPlatformVaultRegistration appPlatformRegistration) + { + var (appPlatformContract, dekKey, macKey) = await VaultManagerService.CreateAppPlatformAsync( + modifiableFolder, + vaultOptions, + cancellationToken); + + // Copies are created because returned DekKey and MacKey are part of the contract + // If they were disposed instead, the Recovery Key screen wouldn't show any keys + using var dekKeyCopy = dekKey.CreateCopy(); + using var macKeyCopy = macKey.CreateCopy(); + + var vaultName = VaultModel.DataModel.DisplayName ?? VaultModel.VaultFolder?.Name; + await appPlatformRegistration.RegisterVaultAsync(_vaultId, vaultName, dekKeyCopy, macKeyCopy, cancellationToken); + + return new CredentialsResult(appPlatformContract, _vaultId); + } + // Create the vault var unlockContract = await VaultManagerService.CreateAsync( modifiableFolder, diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/RecoveryWizardViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/RecoveryWizardViewModel.cs index 69d346223..b390bdc03 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/RecoveryWizardViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/RecoveryWizardViewModel.cs @@ -1,4 +1,8 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Models; @@ -9,10 +13,6 @@ using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; -using System; -using System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Sdk.ViewModels.Views.Wizard { diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/IOidcProvider.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/IOidcProvider.cs new file mode 100644 index 000000000..7158c26f6 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/IOidcProvider.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace SecureFolderFS.Shared.ComponentModel +{ + /// + /// Provides OAuth2/OIDC authentication to obtain access tokens for a server. + /// + public interface IOidcProvider + { + /// + /// Authenticates the user and returns a valid access token. + /// + /// The OIDC authority URL. + /// The OAuth2 client ID. + /// The OAuth2 scopes to request. + /// When true, forces the identity provider to prompt for login instead of silently reusing an existing SSO session. + /// Cancellation token. + /// A that represents the asynchronous operation. Value is a valid Bearer access token. + Task GetAccessTokenAsync(string authority, string clientId, IReadOnlyList scopes, bool forceLogin = false, CancellationToken cancellationToken = default); + } +} diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/IUriLauncher.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/IUriLauncher.cs new file mode 100644 index 000000000..f6d5defc4 --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/IUriLauncher.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading.Tasks; + +namespace SecureFolderFS.Shared.ComponentModel +{ + /// + /// Defines a contract for redirecting or opening a URI. + /// + public interface IUriLauncher + { + /// + /// Launches a URI from app. This can be a URL, folder path, etc. + /// + /// The URI to launch. + /// A that represents the asynchronous operation. + Task OpenUriAsync(Uri uri); + } +} \ No newline at end of file diff --git a/src/Shared/SecureFolderFS.Shared/ComponentModel/Wrapper.cs b/src/Shared/SecureFolderFS.Shared/ComponentModel/Wrapper.cs index a25d464a5..663d2f23b 100644 --- a/src/Shared/SecureFolderFS.Shared/ComponentModel/Wrapper.cs +++ b/src/Shared/SecureFolderFS.Shared/ComponentModel/Wrapper.cs @@ -1,7 +1,7 @@ namespace SecureFolderFS.Shared.ComponentModel { /// - public sealed class Wrapper(T inner) : IWrapper + public class Wrapper(T inner) : IWrapper { /// public T Inner { get; } = inner; diff --git a/src/Shared/SecureFolderFS.Shared/DI.cs b/src/Shared/SecureFolderFS.Shared/DI.cs index ce00eaf07..3fdc6c7dd 100644 --- a/src/Shared/SecureFolderFS.Shared/DI.cs +++ b/src/Shared/SecureFolderFS.Shared/DI.cs @@ -62,7 +62,7 @@ public T GetService() if (_serviceProvider is null) return null; - return GetService(); + return (T?)GetService(typeof(T)); } /// diff --git a/src/Shared/SecureFolderFS.Shared/Extensions/StreamExtensions.cs b/src/Shared/SecureFolderFS.Shared/Extensions/StreamExtensions.cs index 5c9a565f6..c6a79f209 100644 --- a/src/Shared/SecureFolderFS.Shared/Extensions/StreamExtensions.cs +++ b/src/Shared/SecureFolderFS.Shared/Extensions/StreamExtensions.cs @@ -1,6 +1,8 @@ using SecureFolderFS.Shared.Helpers; using System; using System.IO; +using System.Threading; +using System.Threading.Tasks; namespace SecureFolderFS.Shared.Extensions { @@ -90,6 +92,44 @@ public static bool TrySetPositionOrAdvance(this Stream stream, long position) return true; } + /// + public static async ValueTask TrySetPositionOrAdvanceAsync(this Stream stream, long position, CancellationToken cancellationToken = default) + { + var positionInStream = SafetyHelpers.NoFailureResult(() => stream.Position); + if (positionInStream is null) + return false; + + if (positionInStream == position) + return true; + + if (stream.CanSeek) + { + stream.Position = position; + return true; + } + + if (!stream.CanSeek && position < positionInStream) + return false; + + if (!stream.CanRead) + return false; + + // Read to a buffer in loop until the desired position is reached + var bytesToAdvance = position - positionInStream.Value; + var buffer = new byte[4096]; + while (bytesToAdvance > 0) + { + var bytesToRead = (int)Math.Min(buffer.Length, bytesToAdvance); + var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, bytesToRead), cancellationToken).ConfigureAwait(false); + if (bytesRead <= 0) + return false; + + bytesToAdvance -= bytesRead; + } + + return true; + } + /// /// Tries to set the position within the . /// diff --git a/src/Shared/SecureFolderFS.Shared/Helpers/CollisionHelpers.cs b/src/Shared/SecureFolderFS.Shared/Helpers/CollisionHelpers.cs index 6fa98405f..822e45aae 100644 --- a/src/Shared/SecureFolderFS.Shared/Helpers/CollisionHelpers.cs +++ b/src/Shared/SecureFolderFS.Shared/Helpers/CollisionHelpers.cs @@ -19,6 +19,21 @@ public static string GetAvailableName(string desiredName, IEnumerable ex string? format = null) { var existingNamesSet = new HashSet(existingNames, StringComparer.OrdinalIgnoreCase); + return GetAvailableName(desiredName, existingNamesSet, format); + } + + /// + /// Generates an available name that avoids collisions against a prebuilt, case-insensitive set of names. + /// Use this overload in loops to avoid rebuilding the lookup for every item; add each returned + /// name back to so subsequent calls account for it. + /// + /// The desired name that will be used if it does not already exist in the set of existing names. + /// A set of names that the desired name will be checked against to ensure uniqueness. Should use . + /// An optional format string that will be used to generate the new name. The default is "{0} ({1}){2}" where {0} is the base name, {1} is the counter, and {2} is the file extension. + /// A unique name that does not collide with any of the names in the existing set. + public static string GetAvailableName(string desiredName, HashSet existingNamesSet, + string? format = null) + { if (!existingNamesSet.Contains(desiredName)) return desiredName; @@ -37,4 +52,4 @@ public static string GetAvailableName(string desiredName, IEnumerable ex return newName; } } -} \ No newline at end of file +} diff --git a/src/Shared/SecureFolderFS.Shared/Models/AppPlatformVaultOptions.cs b/src/Shared/SecureFolderFS.Shared/Models/AppPlatformVaultOptions.cs index c6861a165..baa796ed7 100644 --- a/src/Shared/SecureFolderFS.Shared/Models/AppPlatformVaultOptions.cs +++ b/src/Shared/SecureFolderFS.Shared/Models/AppPlatformVaultOptions.cs @@ -11,18 +11,6 @@ public sealed record class AppPlatformVaultOptions { [JsonPropertyName("serverUrl")] public required string ServerUrl { get; init; } - - [JsonPropertyName("vaultResource")] - public required string VaultResource { get; init; } - - [JsonPropertyName("organization")] - public string? Organization { get; init; } - - [JsonPropertyName("accessTokenEndpoint")] - public string AccessTokenEndpoint { get; init; } = "/api/app-platform/access-token"; - - [JsonPropertyName("deviceRegistrationEndpoint")] - public string DeviceRegistrationEndpoint { get; init; } = "/api/app-platform/devices"; } } diff --git a/src/Shared/SecureFolderFS.Shared/Models/VaultOptions.cs b/src/Shared/SecureFolderFS.Shared/Models/VaultOptions.cs index 6cd174653..1c00b039c 100644 --- a/src/Shared/SecureFolderFS.Shared/Models/VaultOptions.cs +++ b/src/Shared/SecureFolderFS.Shared/Models/VaultOptions.cs @@ -54,5 +54,15 @@ public sealed record class VaultOptions /// Gets App Platform metadata when the vault is managed by a key broker. /// public AppPlatformVaultOptions? AppPlatform { get; init; } + + /// + /// Gets the rotation counter for complementation key material. + /// + /// + /// This value is a monotonic high-water mark: it must be carried through every configuration + /// rewrite (even while complementation is disabled) so that re-enabling complementation can + /// never reuse a previously issued generation, which would resurrect revoked credentials. + /// + public int ComplementGeneration { get; init; } } } \ No newline at end of file diff --git a/src/Shared/SecureFolderFS.Shared/Models/VaultRestorationParameters.cs b/src/Shared/SecureFolderFS.Shared/Models/VaultRestorationParameters.cs new file mode 100644 index 000000000..6b179db5a --- /dev/null +++ b/src/Shared/SecureFolderFS.Shared/Models/VaultRestorationParameters.cs @@ -0,0 +1,36 @@ +namespace SecureFolderFS.Shared.Models +{ + /// + /// Describes the cryptographic parameters that were detected for a vault whose configuration is being rebuilt. + /// + /// + /// These are surfaced for confirmation before the rebuilt configuration is signed with the vault's own MAC key. + /// + public sealed record class VaultRestorationParameters + { + /// + /// Gets the ID of the detected content cipher. + /// + public required string ContentCipherId { get; init; } + + /// + /// Gets the ID of the detected file name cipher. + /// + public required string FileNameCipherId { get; init; } + + /// + /// Gets the ID of the detected file name encoding. + /// + public required string FileNameEncodingId { get; init; } + + /// + /// Gets the detected threshold for shortening file names. + /// + public required int ShorteningThreshold { get; init; } + + /// + /// Gets whether filename encryption was positively detected by an authenticated decryption. + /// + public required bool IsFileNameEncrypted { get; init; } + } +} diff --git a/src/Shared/SecureFolderFS.Storage/MemoryStorageEx/MemoryFolderEx.cs b/src/Shared/SecureFolderFS.Storage/MemoryStorageEx/MemoryFolderEx.cs index 73fab5733..fb22ce89c 100644 --- a/src/Shared/SecureFolderFS.Storage/MemoryStorageEx/MemoryFolderEx.cs +++ b/src/Shared/SecureFolderFS.Storage/MemoryStorageEx/MemoryFolderEx.cs @@ -42,11 +42,15 @@ public async Task RenameAsync(IStorableChild storable, string ne return newFile; } - case IFolder: + case MemoryFolderEx memoryFolder: { FolderContents.Remove(oldPath); var newFolder = new MemoryFolderEx(newPath, newName, this, _streamSource); newFolder.SetParent(this); + + // A rename has to carry the subtree with it. Identifiers are derived from the parent's + // path, so every descendant is re-created underneath the renamed folder + await newFolder.AdoptContentsFromAsync(memoryFolder, cancellationToken); FolderContents.Add(newPath, newFolder); return newFolder; @@ -56,6 +60,36 @@ public async Task RenameAsync(IStorableChild storable, string ne } } + private async Task AdoptContentsFromAsync(MemoryFolderEx source, CancellationToken cancellationToken) + { + await foreach (var item in source.GetItemsAsync(StorableType.All, cancellationToken)) + { + switch (item) + { + case MemoryFileEx memoryFile: + { + // The stream instance is shared rather than copied, so the contents move with the item + var adoptedFile = new MemoryFileEx(Path.Combine(Id, memoryFile.Name), memoryFile.Name, memoryFile.InternalStream, this, _streamSource); + adoptedFile.SetParent(this); + FolderContents[adoptedFile.Id] = adoptedFile; + + break; + } + + case MemoryFolderEx memoryFolder: + { + var adoptedFolder = new MemoryFolderEx(Path.Combine(Id, memoryFolder.Name), memoryFolder.Name, this, _streamSource); + adoptedFolder.SetParent(this); + + await adoptedFolder.AdoptContentsFromAsync(memoryFolder, cancellationToken); + FolderContents[adoptedFolder.Id] = adoptedFolder; + + break; + } + } + } + } + /// public override async Task CreateFolderAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { diff --git a/tests/SecureFolderFS.Tests/FileSystemTests/BaseReadWriteTests.cs b/tests/SecureFolderFS.Tests/FileSystemTests/BaseReadWriteTests.cs index f8d516546..aa924e20a 100644 --- a/tests/SecureFolderFS.Tests/FileSystemTests/BaseReadWriteTests.cs +++ b/tests/SecureFolderFS.Tests/FileSystemTests/BaseReadWriteTests.cs @@ -64,6 +64,162 @@ protected async Task Base_Write_LargeFile_Read_SameContent_NoThrow() data.SequenceEqual(compareData).Should().BeTrue(); } + protected async Task Base_WriteAsync_LargeFile_ReadAsync_SameContent_NoThrow() + { + ArgumentNullException.ThrowIfNull(StorageRoot); + + // Arrange + var data = new byte[300_000]; + Random.Shared.NextBytes(data); + if (StorageRoot.PlaintextRoot is not IModifiableFolder modifiableFolder) + { + Assert.Fail($"Folder is not {nameof(IModifiableFolder)}."); + return; + } + + // Act + var file = await modifiableFolder.CreateFileAsync("LARGE_FILE_ASYNC"); + await using (var stream = await file.OpenReadWriteAsync()) + { + await stream.WriteAsync(data); + await stream.FlushAsync(); + } + + var compareData = new byte[data.Length]; + await using (var stream = await file.OpenReadWriteAsync()) + { + stream.Length.Should().Be(data.Length); + + var totalRead = 0; + while (totalRead < compareData.Length) + { + var read = await stream.ReadAsync(compareData.AsMemory(totalRead)); + if (read <= 0) + break; + + totalRead += read; + } + + totalRead.Should().Be(data.Length); + } + + // Assert + data.SequenceEqual(compareData).Should().BeTrue(); + } + + protected async Task Base_Write_SparseFile_ReadGap_ReturnsZeros_NoThrow() + { + ArgumentNullException.ThrowIfNull(StorageRoot); + + // Arrange: write a marker far past EOF so the intervening chunks are never written and + // the writer extends the ciphertext with a sparse gap. + // Reading the gap back must return zeros without an integrity error. + // The path guarded by the all-zero chunk check in ChunkReader. + const long gapOffset = 200_000; // spans several plaintext chunks + var marker = new byte[] { 1, 2, 3, 4 }; + if (StorageRoot.PlaintextRoot is not IModifiableFolder modifiableFolder) + { + Assert.Fail($"Folder is not {nameof(IModifiableFolder)}."); + return; + } + + // Act + var file = await modifiableFolder.CreateFileAsync("SPARSE_FILE"); + await using (var stream = await file.OpenReadWriteAsync()) + { + stream.Position = gapOffset; + await stream.WriteAsync(marker); + await stream.FlushAsync(); + } + + // Assert + await using (var readStream = await file.OpenReadWriteAsync()) + { + readStream.Length.Should().Be(gapOffset + marker.Length); + + var gap = new byte[gapOffset]; + readStream.Position = 0; + var totalRead = 0; + while (totalRead < gap.Length) + { + var read = await readStream.ReadAsync(gap.AsMemory(totalRead)); + if (read <= 0) + break; + + totalRead += read; + } + + totalRead.Should().Be(gap.Length); + Array.TrueForAll(gap, static b => b == 0).Should().BeTrue(); + + var readMarker = new byte[marker.Length]; + readStream.Position = gapOffset; + var markerRead = 0; + while (markerRead < readMarker.Length) + { + var read = await readStream.ReadAsync(readMarker.AsMemory(markerRead)); + if (read <= 0) + break; + + markerRead += read; + } + + marker.SequenceEqual(readMarker).Should().BeTrue(); + } + } + + protected async Task Base_SetLength_Truncate_Then_Extend_ReadsZeros_NoThrow() + { + ArgumentNullException.ThrowIfNull(StorageRoot); + + // Arrange: truncate a file to drop a secret, then extend it again on the same handle. + // The extended region must read as zeros. The removed plaintext must not be + // resurrected from the chunk cache and re-encrypted back into the vault + var secret = "SUPER_SECRET_VALUE"u8.ToArray(); + var prefix = new byte[1000]; + Random.Shared.NextBytes(prefix); + if (StorageRoot.PlaintextRoot is not IModifiableFolder modifiableFolder) + { + Assert.Fail($"Folder is not {nameof(IModifiableFolder)}."); + return; + } + + // Act + var file = await modifiableFolder.CreateFileAsync("TRUNCATED_FILE"); + await using (var stream = await file.OpenReadWriteAsync()) + { + await stream.WriteAsync(prefix); + await stream.WriteAsync(secret); + await stream.FlushAsync(); + + stream.SetLength(prefix.Length); + stream.SetLength(prefix.Length + secret.Length); + await stream.FlushAsync(); + } + + // Assert + await using (var readStream = await file.OpenReadWriteAsync()) + { + readStream.Length.Should().Be(prefix.Length + secret.Length); + + var contents = new byte[prefix.Length + secret.Length]; + readStream.Position = 0; + var totalRead = 0; + while (totalRead < contents.Length) + { + var read = await readStream.ReadAsync(contents.AsMemory(totalRead)); + if (read <= 0) + break; + + totalRead += read; + } + + totalRead.Should().Be(contents.Length); + prefix.SequenceEqual(contents.Take(prefix.Length)).Should().BeTrue(); + Array.TrueForAll(contents[prefix.Length..], static b => b == 0).Should().BeTrue(); + } + } + protected async Task Base_Write_SmallFile_Then_WriteAgain_Read_SameContent_NoThrow() { ArgumentNullException.ThrowIfNull(StorageRoot); diff --git a/tests/SecureFolderFS.Tests/FileSystemTests/ReadWriteTests.cs b/tests/SecureFolderFS.Tests/FileSystemTests/ReadWriteTests.cs index a19a8759d..f59a3f88c 100644 --- a/tests/SecureFolderFS.Tests/FileSystemTests/ReadWriteTests.cs +++ b/tests/SecureFolderFS.Tests/FileSystemTests/ReadWriteTests.cs @@ -23,10 +23,28 @@ public async Task Write_LargeFile_Read_SameContent_NoThrow() await Base_Write_LargeFile_Read_SameContent_NoThrow(); } + [Test] + public async Task WriteAsync_LargeFile_ReadAsync_SameContent_NoThrow() + { + await Base_WriteAsync_LargeFile_ReadAsync_SameContent_NoThrow(); + } + [Test] public async Task Write_SmallFile_Then_WriteAgain_Read_SameContent_NoThrow() { await Base_Write_SmallFile_Then_WriteAgain_Read_SameContent_NoThrow(); } + + [Test] + public async Task Write_SparseFile_ReadGap_ReturnsZeros_NoThrow() + { + await Base_Write_SparseFile_ReadGap_ReturnsZeros_NoThrow(); + } + + [Test] + public async Task SetLength_Truncate_Then_Extend_ReadsZeros_NoThrow() + { + await Base_SetLength_Truncate_Then_Extend_ReadsZeros_NoThrow(); + } } } \ No newline at end of file diff --git a/tests/SecureFolderFS.Tests/GlobalSetup.cs b/tests/SecureFolderFS.Tests/GlobalSetup.cs index 416213d38..dffda189a 100644 --- a/tests/SecureFolderFS.Tests/GlobalSetup.cs +++ b/tests/SecureFolderFS.Tests/GlobalSetup.cs @@ -16,7 +16,7 @@ public class GlobalSetup [OneTimeSetUp] public static void GlobalInitialize() { - var settingsFolderPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), Constants.FileNames.SETTINGS_FOLDER_NAME); + var settingsFolderPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), Constants.FileNames.Settings.SETTINGS_FOLDER_NAME); var settingsFolder = new MemoryFolder(settingsFolderPath, Path.GetFileName(settingsFolderPath)); var serviceProvider = ConfigureServices(settingsFolder); diff --git a/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs b/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs index a5f96e5d1..1a6b3e738 100644 --- a/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs +++ b/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs @@ -6,16 +6,13 @@ namespace SecureFolderFS.Tests.Helpers internal static partial class MockVaultHelpers { // Mock recovery key - public const string V4_RECOVERY_KEY = "Ww0/Rx6XHEB87y4WWfw12xo7Xfyk67EB3FNUlWdaG/k=@@@ndVd2mEgV/9Sq9xhLKa4ZaACwQH+7JVzfb7rTLBZK2s="; + public const string V4_RECOVERY_KEY = "xkniDwR94w55X9x4hf7xktz0IwGGal/2JKDMn1w2r9c=@@@KgiXI8E9dZo9K8LshLAnEDbGV8EQs3TQn6MRUGfOmVQ="; private const string V4_KEYSTORE_STRING = """ { - "c_encryptionKey": "wALUX7wq5cZ45yB3HncCiiXZ3OiQmOTZj5MU/T03dxldD3pyh11C5g==", - "c_macKey": "1DrsQmRH4X6CgM08aRqeANYXxN6NWlNrzsbp6DKeXErrE+KfYRS08g==", - "salt": "e5nJCCu+uJZ3uAwio+iXOg==", - "c_softwareEntropy": "bYWy61+0lXUb8e9ZLmasECuCZWmUTaKC8BIJvEofix4=", - "entropyNonce": "jzd/bdatTE2uOoMa", - "entropyTag": "OwjR5RFBmvl7Aaf0rwQ8yw==" + "c_encryptionKey": "pWpPkfqc5hJHKZaH92ccA7HGgMXs99k/GuTMFG1zmimF16+1BbfSRg==", + "c_macKey": "YKQY8WzDf+t6B/LaCG68SNf0NJf/ru6mtt/irsJjVpneBzbmf1Oa/Q==", + "salt": "aSNkObtR5gXuR5uNkeSygw==" } """; @@ -27,9 +24,10 @@ internal static partial class MockVaultHelpers "filenameShortening": 0, "recycleBinSize": 0, "authMode": "password", - "vaultId": "0b47eb66-1e58-451f-a72f-f1f5b34295b6", + "vaultId": "2ecbdd1f-3cd3-4b4f-88d9-d54da869aa3c", "appPlatform": null, - "hmacsha256mac": "fpSCs1rfVwtWeCikRcSeJimORlfN3f+MCjed9nobofA=", + "complementGeneration": 0, + "hmacsha256mac": "ZkYPf3EMwVhS7bjGasakr3CrN54bMRmlZUBvg7hZycw=", "version": 4 } """; diff --git a/tests/SecureFolderFS.Tests/VaultTests/Base4KNameMigrationTests.cs b/tests/SecureFolderFS.Tests/VaultTests/Base4KNameMigrationTests.cs new file mode 100644 index 000000000..0dd824d7e --- /dev/null +++ b/tests/SecureFolderFS.Tests/VaultTests/Base4KNameMigrationTests.cs @@ -0,0 +1,170 @@ +using System.Security.Cryptography; +using FluentAssertions; +using Lex4K; +using NUnit.Framework; +using OwlCore.Storage; +using SecureFolderFS.Core.Cryptography; +using SecureFolderFS.Core.Cryptography.Cipher; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.Extensions; +using SecureFolderFS.Tests.Helpers; +using FileSystemNames = SecureFolderFS.Core.FileSystem.Constants.Names; +using VaultNames = SecureFolderFS.Core.Constants.Vault.Names; + +namespace SecureFolderFS.Tests.VaultTests +{ + /// + /// Covers the re-encoding of Base4K ciphertext names during the V3 to V4 migration. + /// + /// + /// The Base4K implementation was swapped from Lex4K to Secomba's after V3 was released. The two are + /// mutually unreadable, so a Base4K vault whose names were carried over untouched would mount empty. + /// + [TestFixture] + public class Base4KNameMigrationTests + { + /// + /// The whole conversion rests on being able to tell the two encodings apart by inspection alone, + /// which is what makes it idempotent and safe to repeat after an interrupted run. + /// + [Test] + public void Base4K_LegacyAndCurrentEncodings_AreMutuallyUnreadable() + { + for (var length = 17; length < 96; length++) + { + var raw = RandomNumberGenerator.GetBytes(length); + var legacyEncoded = Base4K.EncodeChainToString(raw); + var currentEncoded = SecombaBase4K.Encode(raw); + + legacyEncoded.Should().NotBe(currentEncoded); + + // A legacy name must never look like an already-converted one, or it would be skipped + SecombaBase4K.Decode(legacyEncoded).Should().BeNull(); + + // A converted name must never look like a legacy one, or it would be converted twice + var decodedAsLegacy = TryDecodeLegacy(currentEncoded); + (decodedAsLegacy is null || Base4K.EncodeChainToString(decodedAsLegacy) != currentEncoded).Should().BeTrue(); + } + } + + [Test] + public async Task Create_V3Vault_WithBase4KNames_MigrateTo_V4Vault_ReencodesNames() + { + // Arrange + var (vaultFolder, recoveryKey) = await MockVaultHelpers.CreateVaultV3Async(null); + using var security = CreateSecurity(recoveryKey); + var contentFolder = (IModifiableFolder)await vaultFolder.GetFolderByNameAsync(VaultNames.VAULT_CONTENT_FOLDERNAME); + + // A file at the content root, where no Directory ID applies + var expectedFileName = EncryptName(security, "hello.txt", []); + await contentFolder.CreateFileAsync(ToLegacyName(expectedFileName), false); + + // A folder carrying a Directory ID, holding a file encrypted against that ID + var expectedFolderName = EncryptName(security, "documents", []); + var childFolder = (IModifiableFolder)await contentFolder.CreateFolderAsync(ToLegacyName(expectedFolderName), false); + + var directoryId = RandomNumberGenerator.GetBytes(16); + var directoryIdFile = await childFolder.CreateFileAsync(FileSystemNames.DIRECTORY_ID_FILENAME, false); + await using (var directoryIdStream = await directoryIdFile.OpenWriteAsync()) + await directoryIdStream.WriteAsync(directoryId); + + var expectedNestedName = EncryptName(security, "nested.bin", directoryId); + await childFolder.CreateFileAsync(ToLegacyName(expectedNestedName), false); + + // Act + await MigrateAsync(vaultFolder); + + // Assert + var rootNames = await GetItemNamesAsync(contentFolder); + rootNames.Should().BeEquivalentTo([ + expectedFileName + FileSystemNames.ENCRYPTED_FILE_EXTENSION, + expectedFolderName + FileSystemNames.ENCRYPTED_FILE_EXTENSION + ]); + + var migratedFolder = await contentFolder.GetFolderByNameAsync(expectedFolderName + FileSystemNames.ENCRYPTED_FILE_EXTENSION); + var childNames = await GetItemNamesAsync(migratedFolder); + childNames.Should().BeEquivalentTo([ + FileSystemNames.DIRECTORY_ID_FILENAME, + expectedNestedName + FileSystemNames.ENCRYPTED_FILE_EXTENSION + ]); + + // The Directory ID is raw key material, not an encoded name, and must be carried over as-is + var migratedDirectoryIdFile = await migratedFolder.GetFileByNameAsync(FileSystemNames.DIRECTORY_ID_FILENAME); + await using (var migratedDirectoryIdStream = await migratedDirectoryIdFile.OpenReadAsync()) + { + var buffer = new byte[directoryId.Length]; + _ = await migratedDirectoryIdStream.ReadAtLeastAsync(buffer, buffer.Length, false); + buffer.Should().Equal(directoryId); + } + + // The names must be readable by the current implementation, which is the point of the exercise + security.NameCrypt!.DecryptName(expectedFileName, []).Should().Be("hello.txt"); + security.NameCrypt!.DecryptName(expectedFolderName, []).Should().Be("documents"); + security.NameCrypt!.DecryptName(expectedNestedName, directoryId).Should().Be("nested.bin"); + } + + private static async Task MigrateAsync(IFolder vaultFolder) + { + var service = DI.Service(); + using var migrator = await service.GetMigratorAsync(vaultFolder); + using var keySequence = new KeySequence(); + keySequence.Add(new DisposablePassword(MockVaultHelpers.VAULT_PASSWORD)); + + var contract = await migrator.UnlockAsync(keySequence); + await migrator.MigrateAsync(contract, new()); + } + + private static Security CreateSecurity(string recoveryKey) + { + using var combinedKey = KeyPair.CombineRecoveryKey(recoveryKey); + var keyPair = KeyPair.CopyFromRecoveryKey(combinedKey); + + // Matches the ciphers declared by the V3 mock vault configuration + return Security.CreateNew( + keyPair, + Constants.CipherId.XCHACHA20_POLY1305, + Constants.CipherId.AES_SIV, + Constants.CipherId.ENCODING_BASE4K); + } + + private static string EncryptName(Security security, string plaintextName, ReadOnlySpan directoryId) + { + // Produces the name as the current implementation writes it, which is what the migration must arrive at + return security.NameCrypt!.EncryptName(plaintextName, directoryId); + } + + /// + /// Rewrites a name produced by the current implementation into the form a V3 vault stored it in. + /// + private static string ToLegacyName(string currentEncoded) + { + var raw = SecombaBase4K.Decode(currentEncoded); + raw.Should().NotBeNull(); + + return Base4K.EncodeChainToString(raw!) + FileSystemNames.ENCRYPTED_FILE_EXTENSION; + } + + private static byte[]? TryDecodeLegacy(string encoded) + { + try + { + return Base4K.DecodeChainToNewBuffer(encoded).ToArray(); + } + catch (Exception) + { + return null; + } + } + + private static async Task> GetItemNamesAsync(IFolder folder) + { + var names = new List(); + await foreach (var item in folder.GetItemsAsync()) + names.Add(item.Name); + + return names; + } + } +} diff --git a/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs b/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs index b76e9b09e..c5762bf7f 100644 --- a/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs +++ b/tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs @@ -392,14 +392,16 @@ public async Task ModifyComplementation_ReplaceComplement_UsesNewComplementAndRe var biometricProcedure = new AuthenticationMethod([AUTH_PASSWORD], AUTH_APPLE_BIOMETRIC); using var oldKeyFile = await CreatePasswordVaultWithKeyFileComplementAsync(manager, vaultFolder, "Password#1", vaultId); - using var oldComplementUnlock = oldKeyFile.CreateCopy(); - using var unlockContract = await manager.UnlockAsync(vaultFolder, oldComplementUnlock); + // Replacing the complement rotates the complement secret to a new generation, which can only be + // re-derived from the primary credential (the flow is therefore constrained to a primary login). + using var unlockPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var unlockContract = await manager.UnlockAsync(vaultFolder, unlockPasskey); using var newComplement = SecureKey.CreateSecureRandom(32); // Act await manager.ModifyComplementationAsync(vaultFolder, unlockContract, new() { - CurrentComplementCredential = oldKeyFile, + CurrentPrimaryCredential = unlockPasskey, NewComplementCredential = newComplement }, CreateOptions(biometricProcedure, vaultId)); @@ -416,6 +418,44 @@ public async Task ModifyComplementation_ReplaceComplement_UsesNewComplementAndRe configuredOptions.UnlockProcedure.Should().BeEquivalentTo(biometricProcedure); } + [Test] + public async Task ModifyComplementation_ReplaceComplementWithoutPrimaryCredential_FailsAndPreservesOldCredentials() + { + // Arrange + var vaultFolder = CreateVaultFolder(); + var manager = DI.Service(); + var vaultId = Guid.NewGuid().ToString("N"); + + var biometricProcedure = new AuthenticationMethod([AUTH_PASSWORD], AUTH_APPLE_BIOMETRIC); + using var oldKeyFile = await CreatePasswordVaultWithKeyFileComplementAsync(manager, vaultFolder, "Password#1", vaultId); + + using var oldComplementUnlock = oldKeyFile.CreateCopy(); + using var unlockContract = await manager.UnlockAsync(vaultFolder, oldComplementUnlock); + using var newComplement = SecureKey.CreateSecureRandom(32); + + // Act + // Without the primary credential the complement secret cannot be rotated to a new generation. + // Re-wrapping the old secret instead would let a revoked complement credential combined with an + // old copy of the share file still unlock the vault, so the operation must be rejected. + Func action = () => manager.ModifyComplementationAsync(vaultFolder, unlockContract, new() + { + CurrentComplementCredential = oldKeyFile, + NewComplementCredential = newComplement + }, CreateOptions(biometricProcedure, vaultId)); + + // Assert + await action.Should().ThrowAsync() + .WithMessage("*Current primary credentials*"); + + using var passwordOnlyPasskey = await GetPasswordLoginCredentialAsync("Password#1"); + using var oldComplementPasskey = oldKeyFile.CreateCopy(); + using var newComplementPasskey = newComplement.CreateCopy(); + + (await CanUnlockAsync(manager, vaultFolder, passwordOnlyPasskey)).Should().BeTrue(); + (await CanUnlockAsync(manager, vaultFolder, oldComplementPasskey)).Should().BeTrue(); + (await CanUnlockAsync(manager, vaultFolder, newComplementPasskey)).Should().BeFalse(); + } + [Test] public async Task ModifyComplementation_RemoveComplement_RestoresPrimaryOnlyUnlock() { diff --git a/tests/SecureFolderFS.Tests/VaultTests/MigrationTests.cs b/tests/SecureFolderFS.Tests/VaultTests/MigrationTests.cs index d214df21b..04928f103 100644 --- a/tests/SecureFolderFS.Tests/VaultTests/MigrationTests.cs +++ b/tests/SecureFolderFS.Tests/VaultTests/MigrationTests.cs @@ -1,5 +1,7 @@ -using FluentAssertions; +using System.Security.Cryptography; +using FluentAssertions; using NUnit.Framework; +using SecureFolderFS.Core; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared; using SecureFolderFS.Shared.Models; @@ -58,5 +60,115 @@ public async Task Create_V2Vault_MigrateTo_V3Vault_NoThrow() .Contain(Associations.ASSOC_FILENAME_ENCODING_ID).And .Contain("\"version\": 3"); } + + [Test] + public async Task Create_V3Vault_MigrateTo_V4Vault_NoThrow() + { + // Arrange + var (v3VaultFolder, _) = await MockVaultHelpers.CreateVaultV3Async(null); + var service = DI.Service(); + + // Act + using var migrator = await service.GetMigratorAsync(v3VaultFolder); + using var keySequence = new KeySequence(); + keySequence.Add(new DisposablePassword(MockVaultHelpers.VAULT_PASSWORD)); + + var contract = await migrator.UnlockAsync(keySequence); + await migrator.MigrateAsync(contract, new()); + + // Assert + var v4ConfigFile = await v3VaultFolder.GetFileByNameAsync(Names.VAULT_CONFIGURATION_FILENAME); + var text = await v4ConfigFile.ReadAllTextAsync(); + + text.Should() + .Contain(Associations.ASSOC_FILENAME_SHORTENING).And + .Contain(Associations.ASSOC_FILENAME_ENCODING_ID).And + .Contain("\"version\": 4"); + } + + [Test] + public async Task Create_V3Vault_MigrateTo_V4Vault_UnlocksWithSameCredentials() + { + // Arrange + var (v3VaultFolder, _) = await MockVaultHelpers.CreateVaultV3Async(null); + var vaultService = DI.Service(); + var vaultManagerService = DI.Service(); + + using (var migrator = await vaultService.GetMigratorAsync(v3VaultFolder)) + { + using var keySequence = new KeySequence(); + keySequence.Add(new DisposablePassword(MockVaultHelpers.VAULT_PASSWORD)); + + var contract = await migrator.UnlockAsync(keySequence); + await migrator.MigrateAsync(contract, new()); + } + + // Act + // The keystore is carried over untouched by the migration, so the original password must still open + // the vault. This also exercises the V4 payload MAC, which the unlock routine validates + using var password = new DisposablePassword(MockVaultHelpers.VAULT_PASSWORD); + var unlockContract = await vaultManagerService.UnlockAsync(v3VaultFolder, password); + + // Assert + unlockContract.Should().NotBeNull(); + + var vaultOptions = await vaultService.GetVaultOptionsAsync(v3VaultFolder); + vaultOptions.Version.Should().Be(Versions.V4); + vaultOptions.ShorteningThreshold.Should().Be(0); + vaultOptions.ContentCipherId.Should().Be(Core.Cryptography.Constants.CipherId.XCHACHA20_POLY1305); + vaultOptions.FileNameCipherId.Should().Be(Core.Cryptography.Constants.CipherId.AES_SIV); + vaultOptions.NameEncodingId.Should().Be(Core.Cryptography.Constants.CipherId.ENCODING_BASE4K); + vaultOptions.VaultId.Should().Be("3a169788-6149-4583-ad92-f68113e70e23"); + + unlockContract.Dispose(); + } + + [Test] + public async Task Create_V3Vault_MigrateTo_V4Vault_UsingRecoveryKey_NoThrow() + { + // Arrange + var (v3VaultFolder, recoveryKey) = await MockVaultHelpers.CreateVaultV3Async(null); + var vaultService = DI.Service(); + var vaultManagerService = DI.Service(); + + // Act + using (var migrator = await vaultService.GetMigratorAsync(v3VaultFolder)) + { + var contract = await migrator.RecoverAsync(recoveryKey); + await migrator.MigrateAsync(contract, new()); + } + + // Assert + // Recovering does not re-key the vault, so the configured password keeps working afterwards + using var password = new DisposablePassword(MockVaultHelpers.VAULT_PASSWORD); + var unlockContract = await vaultManagerService.UnlockAsync(v3VaultFolder, password); + + unlockContract.Should().NotBeNull(); + unlockContract.Dispose(); + } + + [Test] + public async Task Create_V3Vault_WithTamperedConfiguration_MigrateTo_V4Vault_Throws() + { + // Arrange + var (v3VaultFolder, _) = await MockVaultHelpers.CreateVaultV3Async(null); + var service = DI.Service(); + + // Downgrade file name encryption without updating the (unforgeable) payload MAC + var configFile = await v3VaultFolder.GetFileByNameAsync(Names.VAULT_CONFIGURATION_FILENAME); + var configText = await configFile.ReadAllTextAsync(); + await configFile.WriteAllTextAsync(configText.Replace("\"filenameCipherScheme\": \"AES-SIV\"", "\"filenameCipherScheme\": \"\"")); + + // Act + using var migrator = await service.GetMigratorAsync(v3VaultFolder); + using var keySequence = new KeySequence(); + keySequence.Add(new DisposablePassword(MockVaultHelpers.VAULT_PASSWORD)); + + // Assert + // Migrating re-signs the configuration with the real MAC key, so a tampered configuration must + // be rejected instead of being turned into a validly signed V4 one + var unlock = async () => await migrator.UnlockAsync(keySequence); + await unlock.Should().ThrowAsync(); + } } }