diff --git a/SecureFolderFS.Public.slnx b/SecureFolderFS.Public.slnx
index 2d771d68c..d1db8ff8b 100644
--- a/SecureFolderFS.Public.slnx
+++ b/SecureFolderFS.Public.slnx
@@ -36,6 +36,8 @@
+
+
diff --git a/SecureFolderFS.slnx b/SecureFolderFS.slnx
index ddfb900cc..4c609c261 100644
--- a/SecureFolderFS.slnx
+++ b/SecureFolderFS.slnx
@@ -93,6 +93,9 @@
+
+
+
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.Cli/SecureFolderFS.Cli.csproj b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
index 59f753706..7b079eb36 100644
--- a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
+++ b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
@@ -15,6 +15,7 @@
+
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">
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs
index a8e5c47cd..6d3046d47 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Browser/TransferControl.xaml.cs
@@ -7,35 +7,74 @@ public partial class TransferControl : ContentView
private const double BOUNCE_LIMIT = -16d;
private const double HIDE_TRANSLATION = 200d;
private const double DISMISS_THRESHOLD = 40d;
+ private const double EXPANDED_DISMISS_THRESHOLD = 120d;
+ private const double COLLAPSED_HEIGHT = 56d;
+ private const double COLLAPSED_INSET = 16d;
+ private const double COLLAPSED_BOTTOM_INSET = 32d;
+ private const double COLLAPSED_CORNER_RADIUS = 12d;
+ private const double EXPANDED_CORNER_RADIUS = 20d;
+ private const double EXPANDED_TOP_INSET = 24d;
+ private const uint EXPAND_DURATION = 300U;
+
private bool _isDismissing;
+ private bool _isExpanded;
+ private bool _isExpanding;
+ private double _expandedHeight;
public TransferControl()
{
InitializeComponent();
}
+ #region Dragging
+
private async void Panel_PanUpdated(object? sender, PanUpdatedEventArgs e)
+ {
+ // The banner is dragged as a whole, but once expanded only the header is a grab area
+ if (_isExpanded)
+ return;
+
+ await HandlePanAsync(e);
+ }
+
+ private async void Header_PanUpdated(object? sender, PanUpdatedEventArgs e)
+ {
+ if (!_isExpanded)
+ return;
+
+ await HandlePanAsync(e);
+ }
+
+ private async Task HandlePanAsync(PanUpdatedEventArgs e)
{
try
{
- if (_isDismissing)
+ if (_isDismissing || _isExpanding)
return;
// Swipe-down dismiss cancels the operation. Disallow it for non-cancellable operations
if (!CanCancel)
{
- if (RootPanel.TranslationY != 0d)
- await RootPanel.TranslateToAsync(0, 0, 250U, Easing.SpringOut);
+ if (Surface.TranslationY != 0d)
+ await Surface.TranslateToAsync(0, 0, 250U, Easing.SpringOut);
return;
}
switch (e.StatusType)
{
+ case GestureStatus.Started:
+ {
+ // A snap-back from a previous drag may still be in flight, and it would
+ // write TranslationY behind the finger's back for the rest of this gesture
+ Surface.CancelAnimations();
+ break;
+ }
+
case GestureStatus.Running:
{
var translation = e.TotalY;
- RootPanel.TranslationY = translation < 0
+ Surface.TranslationY = translation < 0
? Math.Max(translation / 4d, BOUNCE_LIMIT)
: translation;
break;
@@ -44,21 +83,21 @@ private async void Panel_PanUpdated(object? sender, PanUpdatedEventArgs e)
case GestureStatus.Completed:
case GestureStatus.Canceled:
{
- if (RootPanel.TranslationY >= DISMISS_THRESHOLD)
+ // A full-height sheet needs a longer pull than a banner before it reads as a dismissal
+ var threshold = _isExpanded ? EXPANDED_DISMISS_THRESHOLD : DISMISS_THRESHOLD;
+ if (Surface.TranslationY >= threshold)
{
_isDismissing = true;
-
- // Animate out from the current dragged position
- var currentY = RootPanel.TranslationY;
- var remainingDistance = HIDE_TRANSLATION - currentY;
- var duration = (uint)Math.Max(300d * (remainingDistance / HIDE_TRANSLATION), 150d);
- await RootPanel.TranslateToAsync(0, HIDE_TRANSLATION, duration, Easing.CubicInOut);
-
- // Clean up visual state
- RootPanel.IsVisible = false;
- RootPanel.TranslationY = 0d;
-
- _isDismissing = false;
+ try
+ {
+ // Animate out from the current dragged position
+ await AnimateOutAsync(300d);
+ }
+ finally
+ {
+ // Always reset, otherwise a failed animation would wedge the control
+ _isDismissing = false;
+ }
// Tell the caller - they will set IsShown=false, which the guard will skip animating.
// This also ensures the backing value is actually false, so the next IsShown=true fires propertyChanged.
@@ -66,7 +105,7 @@ private async void Panel_PanUpdated(object? sender, PanUpdatedEventArgs e)
}
else
{
- await RootPanel.TranslateToAsync(0, 0, 250U, Easing.SpringOut);
+ await Surface.TranslateToAsync(0, 0, 250U, Easing.SpringOut);
}
break;
}
@@ -77,6 +116,149 @@ private async void Panel_PanUpdated(object? sender, PanUpdatedEventArgs e)
}
}
+ private async Task AnimateOutAsync(double baseDuration)
+ {
+ // An expanded sheet reaches far above the banner, so it has more distance to cover to clear the screen
+ var restingDistance = _isExpanded
+ ? Math.Max(RootPanel.Height, HIDE_TRANSLATION)
+ : HIDE_TRANSLATION;
+
+ // A hard drag can fling the surface past its exit point. Carrying on from wherever the
+ // finger left it beats yanking it back up to the exit point before it leaves
+ var currentY = Surface.TranslationY;
+ var hideTranslation = Math.Max(restingDistance, currentY);
+ var remainingDistance = hideTranslation - currentY;
+ if (remainingDistance > 0.5d)
+ {
+ var duration = (uint)Math.Max(baseDuration * (remainingDistance / restingDistance), 150d);
+ await Surface.TranslateToAsync(0, hideTranslation, duration, Easing.CubicInOut);
+ }
+
+ // Clean up visual state, so the next reveal starts out as a collapsed banner again
+ RootPanel.IsVisible = false;
+ Surface.TranslationY = 0d;
+ ResetExpansion();
+ }
+
+ #endregion
+
+ #region Expanding
+
+ private async void Surface_Tapped(object? sender, TappedEventArgs e)
+ {
+ try
+ {
+ if (_isExpanded || _isExpanding || _isDismissing)
+ return;
+
+ if (!IsError || string.IsNullOrWhiteSpace(ErrorDetails))
+ return;
+
+ // The banner dismisses itself on a timer, which must not happen while the report is being read
+ HoldCommand?.Execute(null);
+ await ExpandAsync();
+ }
+ catch (Exception)
+ {
+ }
+ }
+
+ private async Task ExpandAsync()
+ {
+ _isExpanding = true;
+ try
+ {
+ _expandedHeight = Math.Max(GetAvailableHeight() - EXPANDED_TOP_INSET, COLLAPSED_HEIGHT);
+ _isExpanded = true;
+
+ // A pan anywhere on the panel would fight the report's ScrollView from here on,
+ // so the grab strip over the header takes over as the only drag handle
+ RootPanel.GestureRecognizers.Remove(SurfacePan);
+ DetailsGrabStrip.IsVisible = true;
+ BannerContent.InputTransparent = true;
+ DetailsContent.Opacity = 0d;
+ DetailsContent.IsVisible = true;
+
+ await AnimateAsync("TransferExpansion", EXPAND_DURATION, Easing.CubicInOut, t =>
+ {
+ ApplyExpansion(t);
+
+ // The banner clears out early, so the two states barely overlap during the cross-fade
+ BannerContent.Opacity = Math.Clamp(1d - t * 2.5d, 0d, 1d);
+ DetailsContent.Opacity = Math.Clamp((t - 0.35d) / 0.65d, 0d, 1d);
+ });
+
+ ApplyExpansion(1d);
+ BannerContent.Opacity = 0d;
+ BannerContent.IsVisible = false;
+ DetailsContent.Opacity = 1d;
+ }
+ finally
+ {
+ _isExpanding = false;
+ }
+ }
+
+ ///
+ /// Applies the collapsed (0) to expanded (1) layout, where the control grows upwards
+ /// out of the banner and flushes itself against the bottom and side edges.
+ ///
+ private void ApplyExpansion(double progress)
+ {
+ var inverse = 1d - progress;
+ var topRadius = COLLAPSED_CORNER_RADIUS + (EXPANDED_CORNER_RADIUS - COLLAPSED_CORNER_RADIUS) * progress;
+ var bottomRadius = COLLAPSED_CORNER_RADIUS * inverse;
+
+ Surface.HeightRequest = COLLAPSED_HEIGHT + (_expandedHeight - COLLAPSED_HEIGHT) * progress;
+ Surface.Margin = new Thickness(COLLAPSED_INSET * inverse);
+ SurfaceShape.CornerRadius = new CornerRadius(topRadius, topRadius, bottomRadius, bottomRadius);
+ RootPanel.Margin = new Thickness(0d, 0d, 0d, COLLAPSED_BOTTOM_INSET * inverse);
+ }
+
+ ///
+ /// Restores the collapsed banner without animating. Used once the control is off-screen.
+ ///
+ private void ResetExpansion()
+ {
+ _isExpanded = false;
+ _expandedHeight = COLLAPSED_HEIGHT;
+ ApplyExpansion(0d);
+
+ BannerContent.Opacity = 1d;
+ BannerContent.IsVisible = true;
+ BannerContent.InputTransparent = false;
+ DetailsContent.Opacity = 0d;
+ DetailsContent.IsVisible = false;
+ DetailsGrabStrip.IsVisible = false;
+
+ if (!RootPanel.GestureRecognizers.Contains(SurfacePan))
+ RootPanel.GestureRecognizers.Add(SurfacePan);
+ }
+
+ ///
+ /// Gets the height the expanded sheet may grow into. The control itself is only as tall as
+ /// the banner, so the space is measured against whatever hosts it.
+ ///
+ private double GetAvailableHeight()
+ {
+ var available = (Parent as VisualElement)?.Height ?? 0d;
+ if (available <= 0d)
+ available = Window?.Height ?? 0d;
+
+ return available;
+ }
+
+ private Task AnimateAsync(string name, uint length, Easing easing, Action step)
+ {
+ // Height and margin have no built-in *ToAsync counterpart, so the interpolation is driven by hand
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ new Animation(step).Commit(this, name, 16U, length, easing, (_, _) => tcs.TrySetResult());
+
+ return tcs.Task;
+ }
+
+ #endregion
+
public bool IsShown
{
get => (bool)GetValue(IsShownProperty);
@@ -89,28 +271,33 @@ public bool IsShown
if (newValue is not bool bValue || bindable is not TransferControl tc)
return;
- if (tc._isDismissing)
+ try
{
- // Gesture already handled the animation; just ensure a clean state
- tc.RootPanel.IsVisible = false;
- tc.RootPanel.TranslationY = 0d;
- return;
- }
+ if (tc._isDismissing)
+ {
+ // Gesture already handled the animation; just ensure a clean state
+ tc.RootPanel.IsVisible = false;
+ tc.Surface.TranslationY = 0d;
+ tc.ResetExpansion();
+ return;
+ }
- if (bValue)
- {
- tc.RootPanel.TranslationY = HIDE_TRANSLATION;
- tc.RootPanel.IsVisible = true;
- await tc.RootPanel.TranslateToAsync(0, 0, 350U, Easing.CubicInOut);
+ if (bValue)
+ {
+ // Whatever was left expanded belongs to the previous operation
+ tc.ResetExpansion();
+ tc.Surface.TranslationY = HIDE_TRANSLATION;
+ tc.RootPanel.IsVisible = true;
+ await tc.Surface.TranslateToAsync(0, 0, 350U, Easing.CubicInOut);
+ }
+ else
+ {
+ await tc.AnimateOutAsync(350d);
+ }
}
- else
+ catch (Exception)
{
- var currentY = tc.RootPanel.TranslationY;
- var remainingDistance = HIDE_TRANSLATION - currentY;
- var duration = (uint)Math.Max(350d * (remainingDistance / HIDE_TRANSLATION), 150d);
- await tc.RootPanel.TranslateToAsync(0, HIDE_TRANSLATION, duration, Easing.CubicInOut);
- tc.RootPanel.IsVisible = false;
- tc.RootPanel.TranslationY = 0d;
+ // An async void handler must never let an animation failure reach the app
}
});
@@ -146,6 +333,14 @@ public bool IsError
public static readonly BindableProperty IsErrorProperty =
BindableProperty.Create(nameof(IsError), typeof(bool), typeof(TransferControl), false);
+ public bool IsSuccess
+ {
+ get => (bool)GetValue(IsSuccessProperty);
+ set => SetValue(IsSuccessProperty, value);
+ }
+ public static readonly BindableProperty IsSuccessProperty =
+ BindableProperty.Create(nameof(IsSuccess), typeof(bool), typeof(TransferControl), false);
+
public string? Title
{
get => (string?)GetValue(TitleProperty);
@@ -154,6 +349,14 @@ public string? Title
public static readonly BindableProperty TitleProperty =
BindableProperty.Create(nameof(Title), typeof(string), typeof(TransferControl));
+ public string? ErrorDetails
+ {
+ get => (string?)GetValue(ErrorDetailsProperty);
+ set => SetValue(ErrorDetailsProperty, value);
+ }
+ public static readonly BindableProperty ErrorDetailsProperty =
+ BindableProperty.Create(nameof(ErrorDetails), typeof(string), typeof(TransferControl));
+
public string? PrimaryButtonText
{
get => (string?)GetValue(PrimaryButtonTextProperty);
@@ -177,5 +380,13 @@ public ICommand? PrimaryCommand
}
public static readonly BindableProperty PrimaryCommandProperty =
BindableProperty.Create(nameof(PrimaryCommand), typeof(ICommand), typeof(TransferControl));
+
+ public ICommand? HoldCommand
+ {
+ get => (ICommand?)GetValue(HoldCommandProperty);
+ set => SetValue(HoldCommandProperty, value);
+ }
+ public static readonly BindableProperty HoldCommandProperty =
+ BindableProperty.Create(nameof(HoldCommand), typeof(ICommand), typeof(TransferControl));
}
}
diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/CommandBarControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/CommandBarControl.xaml
index 489f2f708..f5f36a42c 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/CommandBarControl.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/CommandBarControl.xaml
@@ -17,7 +17,7 @@
-
+
@@ -61,6 +61,7 @@
mi:MauiIcon.Value="{mi_material:Material Close,
IconSize=20}"
Clicked="Close_Clicked"
+ SemanticProperties.Description="{l:ResourceString Rid=Close}"
Style="{StaticResource TransparentButtonStyle}"
VerticalOptions="Center" />
@@ -76,12 +77,22 @@
+
@@ -115,6 +127,7 @@
IconColor='#007bff'}"
Command="{Binding PropertiesCommand, Source={x:Reference ThisControl}}"
IsVisible="{Binding PropertiesCommand, Converter={StaticResource NullToBoolConverter}, Source={x:Reference ThisControl}}"
+ SemanticProperties.Description="{l:ResourceString Rid=GetInfo}"
Style="{StaticResource NoBackgroundButtonStyle}"
VerticalOptions="Center" />
+
diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/CommandBarControl.xaml.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/CommandBarControl.xaml.cs
index 5d1f001bc..d703e1404 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/CommandBarControl.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/CommandBarControl.xaml.cs
@@ -25,20 +25,32 @@ private void UpdateToolbarOnTop(bool value)
{
if (value)
{
+ // Content sits below the toolbar: size the toolbar row to the actual toolbar
+ // height instead of the fixed overlay height, otherwise a large gap appears
+ // between the toolbar and the content
TopBorder.Background = Colors.Transparent;
+ TopBorder.HeightRequest = -1d;
+ ToolbarRow.Height = GridLength.Auto;
Grid.SetRowSpan(TopBorder, 1);
Grid.SetRowSpan(MainContent, 1);
Grid.SetRow(MainContent, 1);
- MainContent.Margin = new(0, 24, 0, 0);
+ MainContent.Margin = new(0);
}
else
{
+ // Toolbar overlays the content (e.g. media previews) - restore the taller
+ // plate that hosts the fade-out gradient
#if ANDROID
TopBorder.Background = Resources["BarGradient"] as Brush;
+ TopBorder.HeightRequest = 104d;
+#else
+ TopBorder.HeightRequest = 192d;
#endif
+ ToolbarRow.Height = new GridLength(75d);
Grid.SetRowSpan(TopBorder, 2);
Grid.SetRow(MainContent, 0);
Grid.SetRowSpan(MainContent, 2);
+ MainContent.Margin = new(0);
}
}
@@ -103,6 +115,14 @@ public ICommand? PropertiesCommand
public static readonly BindableProperty PropertiesCommandProperty =
BindableProperty.Create(nameof(PropertiesCommand), typeof(ICommand), typeof(CommandBarControl));
+ public ICommand? DeleteCommand
+ {
+ get => (ICommand?)GetValue(DeleteCommandProperty);
+ set => SetValue(DeleteCommandProperty, value);
+ }
+ public static readonly BindableProperty DeleteCommandProperty =
+ BindableProperty.Create(nameof(DeleteCommand), typeof(ICommand), typeof(CommandBarControl));
+
public ICommand? ShareCommand
{
get => (ICommand?)GetValue(ShareCommandProperty);
diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/LoginControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/LoginControl.xaml
index 782c273fd..d3e2aeda4 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/LoginControl.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/LoginControl.xaml
@@ -10,6 +10,7 @@
xmlns:ts="using:SecureFolderFS.Maui.TemplateSelectors"
xmlns:vm="clr-namespace:SecureFolderFS.UI.ViewModels.Authentication;assembly=SecureFolderFS.UI"
xmlns:vm2="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls.Authentication;assembly=SecureFolderFS.Sdk"
+ xmlns:vm3="clr-namespace:SecureFolderFS.Sdk.ViewModels.Controls.Components;assembly=SecureFolderFS.Sdk"
x:Name="ThisLoginControl">
@@ -100,6 +101,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -123,6 +217,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -146,10 +333,12 @@
diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/RegisterControl.xaml b/src/Platforms/SecureFolderFS.Maui/UserControls/RegisterControl.xaml
index e76fae2f1..df00598f9 100644
--- a/src/Platforms/SecureFolderFS.Maui/UserControls/RegisterControl.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/UserControls/RegisterControl.xaml
@@ -81,12 +81,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/src/Platforms/SecureFolderFS.Maui/ValueConverters/FileIconConverter.cs b/src/Platforms/SecureFolderFS.Maui/ValueConverters/FileIconConverter.cs
index 385aafcf2..6c954084b 100644
--- a/src/Platforms/SecureFolderFS.Maui/ValueConverters/FileIconConverter.cs
+++ b/src/Platforms/SecureFolderFS.Maui/ValueConverters/FileIconConverter.cs
@@ -1,11 +1,10 @@
using System.Globalization;
+using System.Runtime.CompilerServices;
using OwlCore.Storage;
-using SecureFolderFS.Maui.AppModels;
using SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser;
using SecureFolderFS.Shared.ComponentModel;
using SecureFolderFS.Shared.Enums;
using SecureFolderFS.Shared.Extensions;
-using SecureFolderFS.Shared.Models;
using IImage = SecureFolderFS.Shared.ComponentModel.IImage;
namespace SecureFolderFS.Maui.ValueConverters
@@ -17,6 +16,12 @@ namespace SecureFolderFS.Maui.ValueConverters
///
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 @@
+ Text="{l:ResourceString Rid=FileModified}"
+ VerticalOptions="Center" />
+
public void SetView(IViewable viewable)
{
+ if (ViewModel is not null)
+ ViewModel.CloseRequested -= ViewModel_CloseRequested;
+
ViewModel = viewable as PreviewerOverlayViewModel;
+ if (ViewModel is not null)
+ ViewModel.CloseRequested += ViewModel_CloseRequested;
+
OnPropertyChanged(nameof(ViewModel));
}
+ private async void ViewModel_CloseRequested(object? sender, EventArgs e)
+ {
+ await HideAsync();
+ }
+
///
public async Task HideAsync()
{
@@ -70,9 +82,22 @@ public async Task HideAsync()
protected override void OnDisappearing()
{
base.OnDisappearing();
+
+ // OnDisappearing also fires when the page is merely covered (share sheet,
+ // app switch, nested prompt). Only tear down when the modal was actually
+ // dismissed, otherwise the gallery would come back with dead gestures
+ if (IsStillPresented())
+ return;
+
_modalTcs.TrySetResult(Result.Success);
+ if (ViewModel is not null)
+ ViewModel.CloseRequested -= ViewModel_CloseRequested;
+
if (GalleryView is not null)
{
+ if (GalleryView.BindingContext is CarouselPreviewerViewModel carouselViewModel)
+ carouselViewModel.Slides.CollectionChanged -= Slides_CollectionChanged;
+
GalleryView.PreviousRequested -= Gallery_PreviousRequested;
GalleryView.NextRequested -= Gallery_NextRequested;
GalleryView.DismissRequested -= Gallery_DismissRequested;
@@ -84,6 +109,12 @@ protected override void OnDisappearing()
}
}
+ private bool IsStillPresented()
+ {
+ // The page is wrapped in a NavigationPage before being pushed modally
+ return _sourceNavigation.ModalStack.Any(page => page == this || (page as NavigationPage)?.CurrentPage == this);
+ }
+
private View? CreateGalleryView(CarouselPreviewerViewModel carouselViewModel, int index)
{
var viewModel = carouselViewModel.Slides.ElementAtOrDefault(index);
@@ -100,30 +131,41 @@ protected override void OnDisappearing()
AudioTemplate = Resources["AudioTemplate"] as DataTemplate
}
};
+
+ // The template content is created synchronously, so the gesture commands can be
+ // wired immediately. Relying on Loaded alone was racy on Android, where the event
+ // can fire at unpredictable times (or the x:Reference bindings may not have
+ // resolved yet), leaving the gallery without swipe handling
+ WirePresentationCommands(presentation);
presentation.Loaded += Presentation_Loaded;
return presentation;
void Presentation_Loaded(object? sender, EventArgs e)
{
presentation.Loaded -= Presentation_Loaded;
- var descendants = presentation.GetVisualTreeDescendants();
- var found = descendants.FirstOrDefault(x => x is PanPinchContainer or PanRouter);
+ WirePresentationCommands(presentation);
+ }
+ }
+
+ private void WirePresentationCommands(ContentPresentation presentation)
+ {
+ var descendants = presentation.GetVisualTreeDescendants();
+ var found = descendants.FirstOrDefault(x => x is PanPinchContainer or PanRouter);
+
+ switch (found)
+ {
+ case PanPinchContainer panPinchContainer:
+ {
+ panPinchContainer.TappedCommand ??= ViewModel?.ToggleImmersionCommand;
+ panPinchContainer.PanUpdatedCommand ??= GalleryView?.PanUpdatedCommand;
+ break;
+ }
- switch (found)
+ case PanRouter panRouter:
{
- case PanPinchContainer panPinchContainer:
- {
- panPinchContainer.TappedCommand ??= ViewModel?.ToggleImmersionCommand;
- panPinchContainer.PanUpdatedCommand ??= GalleryView?.PanUpdatedCommand;
- break;
- }
-
- case PanRouter panRouter:
- {
- panRouter.TappedCommand ??= ViewModel?.ToggleImmersionCommand;
- panRouter.PanUpdatedCommand ??= GalleryView?.PanUpdatedCommand;
- break;
- }
+ panRouter.TappedCommand ??= ViewModel?.ToggleImmersionCommand;
+ panRouter.PanUpdatedCommand ??= GalleryView?.PanUpdatedCommand;
+ break;
}
}
}
@@ -175,10 +217,10 @@ private void MediaPlayerElement_Loaded(object? sender, EventArgs e)
if (stream.CanSeek)
stream.Position = 0L;
- var libVlc = new LibVLC("--input-repeat=65545");
+ var libVlc = new LibVLC("--input-repeat=65535");
var mediaInput = new StreamMediaInput(stream);
var media = new Media(libVlc, mediaInput);
- media.AddOption(":input-repeat=65545");
+ media.AddOption(":input-repeat=65535");
var mediaPlayer = new LibVLCSharp.Shared.MediaPlayer(libVlc)
{
Media = media
@@ -211,6 +253,7 @@ private void GalleryView_Loaded(object? sender, EventArgs e)
galleryView.PreviousRequested += Gallery_PreviousRequested;
galleryView.NextRequested += Gallery_NextRequested;
galleryView.DismissRequested += Gallery_DismissRequested;
+ carouselViewModel.Slides.CollectionChanged += Slides_CollectionChanged;
(carouselViewModel.Slides.ElementAtOrDefault(carouselViewModel.CurrentIndex - 1) as IAsyncInitialize)?.InitAsync();
(carouselViewModel.Slides.ElementAtOrDefault(carouselViewModel.CurrentIndex) as IAsyncInitialize)?.InitAsync();
@@ -225,6 +268,31 @@ private void GalleryView_Loaded(object? sender, EventArgs e)
carouselViewModel.Title = carouselViewModel.Slides.ElementAtOrDefault(carouselViewModel.CurrentIndex)?.Title;
}
+ private void Slides_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ // Rebuild the visible views around the (already adjusted) current index,
+ // e.g. after the current slide was deleted
+ if (GalleryView is not { BindingContext: CarouselPreviewerViewModel carouselViewModel } galleryView)
+ return;
+
+ if (carouselViewModel.Slides.Count == 0)
+ return; // The view model requests the overlay to close
+
+ var index = carouselViewModel.CurrentIndex;
+ (carouselViewModel.Slides.ElementAtOrDefault(index - 1) as IAsyncInitialize)?.InitAsync();
+ (carouselViewModel.Slides.ElementAtOrDefault(index + 1) as IAsyncInitialize)?.InitAsync();
+ (carouselViewModel.Slides.ElementAtOrDefault(index) as IViewDesignation)?.OnAppearing();
+
+ (galleryView.Previous as IDisposable)?.Dispose();
+ (galleryView.Current as IDisposable)?.Dispose();
+ (galleryView.Next as IDisposable)?.Dispose();
+
+ galleryView.Previous = index > 0 ? CreateGalleryView(carouselViewModel, index - 1) : null;
+ galleryView.Current = CreateGalleryView(carouselViewModel, index);
+ galleryView.Next = index < carouselViewModel.Slides.Count - 1 ? CreateGalleryView(carouselViewModel, index + 1) : null;
+ galleryView.RefreshLayout();
+ }
+
private void Gallery_PreviousRequested(object? sender, EventArgs e)
{
if (sender is not GalleryView { BindingContext: CarouselPreviewerViewModel carouselViewModel } galleryView)
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml
index 6d413dada..ad46fb1d0 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml
@@ -87,26 +87,107 @@
IsSelecting="{Binding ViewModel.IsSelecting, Mode=OneWay}"
ItemsSource="{Binding ViewModel.CurrentFolder.Items, Mode=OneWay}"
RefreshCommand="{Binding ViewModel.RefreshCommand, Mode=OneWay}"
+ ViewModel="{Binding ViewModel, Mode=OneWay}"
ViewType="{Binding ViewModel.Layouts.BrowserViewType, Mode=OneWay}">
-
+ Spacing="16"
+ VerticalOptions="Center">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml.cs
index dc469d1f7..24692bd8f 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Vault/BrowserPage.xaml.cs
@@ -122,8 +122,13 @@ protected override void OnAppearing()
if (ViewModel?.OuterNavigator is MauiNavigationService navigationService)
navigationService.SetCurrentViewInternal(ViewModel);
- // Also update the initial layout
- if (Browser.CanReloadCollection())
+ // Re-apply the adaptive layout when returning to the folder.
+ // The full ListContentsAsync refresh is deliberately not triggered here because
+ // it would rebuild the collection and reset the scroll position every time an overlay (e.g., the previewer) closes
+ ViewModel?.CurrentFolder?.OnAppearing();
+
+ // Only hide the browser when a reload will actually recreate the collection
+ if (Browser.CanReloadCollection() && Browser.NeedsCollectionReload())
Browser.IsVisible = false;
base.OnAppearing();
diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml
index f50a88be5..e8c9bd7d2 100644
--- a/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml
+++ b/src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml
@@ -87,7 +87,7 @@
Command="{Binding ViewModel.LoginViewModel.RecoverAccessCommand}"
HorizontalOptions="{OnPlatform Android={LayoutOptions Alignment=Fill},
iOS={LayoutOptions Alignment=Center}}"
- IsVisible="{Binding ViewModel.LoginViewModel.CurrentViewModel, Mode=OneWay, Converter={StaticResource TypeNameBoolConverter}, ConverterParameter='MigrationViewModel,ErrorViewModel|invert'}"
+ IsVisible="{Binding ViewModel.LoginViewModel.CurrentViewModel, Mode=OneWay, Converter={StaticResource TypeNameBoolConverter}, ConverterParameter='MigrationViewModel,ErrorViewModel,RecoveryRequirementViewModel|invert'}"
Style="{OnPlatform Android={StaticResource TransparentButtonStyle},
iOS={StaticResource NoBackgroundButtonStyle}}"
Text="{l:ResourceString Rid=RecoverAccess}" />
diff --git a/src/Platforms/SecureFolderFS.UI/AppModels/VaultDataSourceJsonConverter.cs b/src/Platforms/SecureFolderFS.UI/AppModels/VaultDataSourceJsonConverter.cs
index ec63d8a4f..a42778987 100644
--- a/src/Platforms/SecureFolderFS.UI/AppModels/VaultDataSourceJsonConverter.cs
+++ b/src/Platforms/SecureFolderFS.UI/AppModels/VaultDataSourceJsonConverter.cs
@@ -6,7 +6,7 @@
namespace SecureFolderFS.UI.AppModels
{
///
- internal sealed class VaultDataSourceJsonConverter : JsonConverter
+ public sealed class VaultDataSourceJsonConverter : JsonConverter
{
///
public override VaultStorageSourceDataModel? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
diff --git a/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/Media/IntroductionReveal.wav b/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/Media/IntroductionReveal.wav
new file mode 100644
index 000000000..1b48b4482
Binary files /dev/null and b/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/Media/IntroductionReveal.wav differ
diff --git a/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/app_platform_icon.png b/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/app_platform_icon.png
new file mode 100644
index 000000000..054f52437
Binary files /dev/null and b/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/app_platform_icon.png differ
diff --git a/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/app_platform_icon.svg b/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/app_platform_icon.svg
new file mode 100644
index 000000000..3ee84a804
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/app_platform_icon.svg
@@ -0,0 +1,74 @@
+
diff --git a/src/Platforms/SecureFolderFS.UI/Constants.cs b/src/Platforms/SecureFolderFS.UI/Constants.cs
index 4b93f0bdb..d15f23a5e 100644
--- a/src/Platforms/SecureFolderFS.UI/Constants.cs
+++ b/src/Platforms/SecureFolderFS.UI/Constants.cs
@@ -6,6 +6,7 @@ public static class Constants
public const string MAIN_INSTANCE_ID = "SecureFolderFS_maininstance";
public const string DATA_CONTAINER_ID = "SecureFolderFS_datacontainer";
public const string STORABLE_BOOKMARK_RID = "bookmark:";
+ public const string AUTOSTART_ARGUMENT = "--autostart";
public static class GitHub
{
@@ -16,13 +17,26 @@ public static class GitHub
public static class FileNames
{
public const string KEY_FILE_EXTENSION = ".key";
- public const string VAULTS_WIDGETS_FOLDERNAME = "vaults_widgets";
- public const string SETTINGS_FOLDER_NAME = "settings";
- public const string APPLICATION_SETTINGS_FILENAME = "application_settings.json";
- public const string SAVED_VAULTS_FILENAME = "saved_vaults.json";
- public const string USER_SETTINGS_FILENAME = "user_settings.json";
- public const string ICON_ASSET_PATH = "Assets/AppAssets/app_icon.ico";
public const string VAULT_SHORTCUT_FILE_EXTENSION = ".sfvault";
+ public const string ICON_ASSET_PATH = "Assets/AppAssets/app_icon.ico";
+
+ public static class Accounts
+ {
+ public const string ACCOUNTS_FOLDER_NAME = "accounts";
+ public const string ACCOUNT_DEVICE_KEY_FILENAME = "device_key.dat";
+ public const string ACCOUNT_DEVICE_ID_FILENAME = "device_id.dat";
+ public const string ACCOUNT_METADATA_FILENAME = "account_metadata.dat";
+ public const string ACCOUNT_CLIENT_DEVICE_ID_FILENAME = "client_device_id.dat";
+ }
+
+ public static class Settings
+ {
+ public const string VAULTS_WIDGETS_FOLDERNAME = "vaults_widgets";
+ public const string SETTINGS_FOLDER_NAME = "settings";
+ public const string APPLICATION_SETTINGS_FILENAME = "application_settings.json";
+ public const string SAVED_VAULTS_FILENAME = "saved_vaults.json";
+ public const string USER_SETTINGS_FILENAME = "user_settings.json";
+ }
}
public static class AppThemes
diff --git a/src/Platforms/SecureFolderFS.UI/DataModels/DeviceLinkVaultDataModel.cs b/src/Platforms/SecureFolderFS.UI/DataModels/DeviceLinkVaultDataModel.cs
deleted file mode 100644
index 9ce1ebc61..000000000
--- a/src/Platforms/SecureFolderFS.UI/DataModels/DeviceLinkVaultDataModel.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using System;
-using System.ComponentModel;
-using System.Text.Json.Serialization;
-using SecureFolderFS.Core.DataModels;
-
-namespace SecureFolderFS.UI.DataModels
-{
- [Serializable]
- public sealed record DeviceLinkVaultDataModel : VaultChallengeDataModel
- {
- ///
- /// The Credential ID (CID) that binds this vault to a mobile credential.
- ///
- [JsonPropertyName("credentialId")]
- [DefaultValue(null)]
- public required string? CredentialId { get; init; }
-
- ///
- /// Gets or sets the unique identifier of the endpoint device.
- ///
- [JsonPropertyName("endpointDeviceId")]
- [DefaultValue(null)]
- public string? EndpointDeviceId { get; set; }
-
- ///
- /// The mobile credential's signing public key (Base64).
- /// Used to verify challenge signatures.
- ///
- [JsonPropertyName("publicSigningKey")]
- [DefaultValue(null)]
- public required byte[]? PublicSigningKey { get; init; }
-
- ///
- /// Unique pairing identifier shared between desktop and mobile.
- ///
- [JsonPropertyName("pairingId")]
- [DefaultValue(null)]
- public required string? PairingId { get; init; }
- }
-}
diff --git a/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs b/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs
index 446084404..a4d81faf5 100644
--- a/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs
+++ b/src/Platforms/SecureFolderFS.UI/Helpers/BaseLifecycleHelper.cs
@@ -25,7 +25,7 @@ public abstract class BaseLifecycleHelper : IAsyncInitialize
///
public virtual Task InitAsync(CancellationToken cancellationToken = default)
{
- 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.UI/SecureFolderFS.UI.csproj b/src/Platforms/SecureFolderFS.UI/SecureFolderFS.UI.csproj
index d9504ad99..6fd4aebc1 100644
--- a/src/Platforms/SecureFolderFS.UI/SecureFolderFS.UI.csproj
+++ b/src/Platforms/SecureFolderFS.UI/SecureFolderFS.UI.csproj
@@ -4,6 +4,7 @@
net10.0disableenable
+ true
@@ -18,6 +19,9 @@
+
+
+
@@ -26,6 +30,8 @@
+
+
@@ -39,6 +45,7 @@
+
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/AppPlatformAccountProvider.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/AppPlatformAccountProvider.cs
new file mode 100644
index 000000000..501392c7c
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/AppPlatformAccountProvider.cs
@@ -0,0 +1,53 @@
+#if APP_PLATFORM_PRESENT
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.AppPlatform.Services;
+using SecureFolderFS.Sdk.Models;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Shared;
+
+namespace SecureFolderFS.UI.ServiceImplementation
+{
+ ///
+ /// for App Platform device-key identities, backed by .
+ ///
+ public sealed class AppPlatformAccountProvider : IAccountProvider
+ {
+ private readonly IDeviceKeyStore _deviceKeyStore;
+
+ ///
+ public string ProviderId { get; } = Core.Constants.Vault.Authentication.AUTH_APP_PLATFORM;
+
+ public AppPlatformAccountProvider(IDeviceKeyStore deviceKeyStore)
+ {
+ _deviceKeyStore = deviceKeyStore;
+ }
+
+ ///
+ public async Task> GetAccountsAsync(CancellationToken cancellationToken = default)
+ {
+ var mediaService = DI.Service();
+ var icon = await mediaService.GetImageFromResourceAsync("AppPlatformIcon", cancellationToken);
+
+ var accounts = await _deviceKeyStore.GetAccountsAsync(cancellationToken);
+ return accounts
+ .Select(x => new AccountModel(
+ x.Id,
+ x.DisplayName,
+ x.ServerUrl,
+ icon,
+ ProviderId))
+ .ToImmutableList();
+ }
+
+ ///
+ public Task RemoveAccountAsync(string accountId, CancellationToken cancellationToken = default)
+ {
+ return _deviceKeyStore.ClearAsync(accountId, cancellationToken);
+ }
+ }
+}
+#endif
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/BaseVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/BaseVaultCredentialsService.cs
index ff1a2a4b2..742849153 100644
--- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/BaseVaultCredentialsService.cs
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/BaseVaultCredentialsService.cs
@@ -6,6 +6,7 @@
using System.Threading;
using System.Threading.Tasks;
using OwlCore.Storage;
+using SecureFolderFS.Core.DataModels;
using SecureFolderFS.Core.VaultAccess;
using SecureFolderFS.Sdk.Extensions;
using SecureFolderFS.Sdk.Services;
@@ -88,8 +89,10 @@ public virtual async Task FromUnlockProcedureAsync(IFolder vaultFolder,
///
public virtual async IAsyncEnumerable GetLoginAsync(IFolder vaultFolder, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
+ // Only the authentication members are read so that the login sequence can also be assembled for
+ // outdated vaults, whose configuration cannot be parsed as the latest format (see the migrators)
var vaultReader = new VaultReader(vaultFolder, StreamSerializer.Instance);
- var config = await vaultReader.ReadConfigurationAsync(cancellationToken);
+ var config = await vaultReader.ReadConfigurationAsync(cancellationToken);
var authenticationMethod = AuthenticationMethod.FromString(config.AuthenticationMethod);
cancellationToken.ThrowIfCancellationRequested();
@@ -107,7 +110,7 @@ public virtual async IAsyncEnumerable GetLoginAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var vaultReader = new VaultReader(vaultFolder, StreamSerializer.Instance);
- var config = await vaultReader.ReadConfigurationAsync(cancellationToken);
+ var config = await vaultReader.ReadConfigurationAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
await foreach (var item in GetLoginAsync(vaultFolder, unlockProcedure, config.Uid, cancellationToken))
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/FileDeviceKeyStore.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/FileDeviceKeyStore.cs
new file mode 100644
index 000000000..460b9b137
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/FileDeviceKeyStore.cs
@@ -0,0 +1,317 @@
+#if APP_PLATFORM_PRESENT
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using OwlCore.Storage;
+using SecureFolderFS.Sdk.AppPlatform.Services;
+using SecureFolderFS.Storage.Extensions;
+using static SecureFolderFS.UI.Constants.FileNames.Accounts;
+
+namespace SecureFolderFS.UI.ServiceImplementation
+{
+ ///
+ /// File-based . Stores device key material for one or more accounts, each in its own subfolder under a base folder.
+ ///
+ public abstract class FileDeviceKeyStore : IDeviceKeyStore
+ {
+ private readonly IModifiableFolder _baseFolder;
+
+ protected FileDeviceKeyStore(IModifiableFolder baseFolder)
+ {
+ _baseFolder = baseFolder;
+ }
+
+ ///
+ public async Task GetOrCreateClientDeviceIdAsync(CancellationToken cancellationToken = default)
+ {
+ var accountsFolder = await _baseFolder.CreateFolderAsync(ACCOUNTS_FOLDER_NAME, false, cancellationToken);
+ if (accountsFolder is not IModifiableFolder modifiableFolder)
+ throw new InvalidOperationException("The accounts folder is not modifiable.");
+
+ var file = await modifiableFolder.TryGetFileByNameAsync(ACCOUNT_CLIENT_DEVICE_ID_FILENAME, cancellationToken);
+ if (file is not null)
+ {
+ // If the stored id can no longer be decrypted, TryReadProtectedTextAsync discards it
+ // and returns null so a fresh id is generated below.
+ var text = await TryReadProtectedTextAsync(file, modifiableFolder, cancellationToken);
+ if (Guid.TryParse(text, out var existing))
+ return existing;
+ }
+
+ var idFile = await modifiableFolder.CreateFileAsync(ACCOUNT_CLIENT_DEVICE_ID_FILENAME, true, cancellationToken);
+ var clientDeviceId = Guid.NewGuid();
+ await WriteProtectedTextAsync(idFile, clientDeviceId.ToString(), cancellationToken);
+
+ return clientDeviceId;
+ }
+
+ ///
+ public async Task> GetAccountsAsync(CancellationToken cancellationToken = default)
+ {
+ var accounts = new List();
+ var accountsFolder = await _baseFolder.TryGetFolderByNameAsync(ACCOUNTS_FOLDER_NAME, cancellationToken);
+ if (accountsFolder is null)
+ return accounts;
+
+ await foreach (var item in accountsFolder.GetItemsAsync(StorableType.Folder, cancellationToken))
+ {
+ if (item is not IChildFolder accountFolder)
+ continue;
+
+ if (await accountFolder.TryGetFirstByNameAsync(ACCOUNT_DEVICE_KEY_FILENAME, cancellationToken) is null)
+ continue;
+
+ if (await accountFolder.TryGetFileByNameAsync(ACCOUNT_METADATA_FILENAME, cancellationToken) is IFile metaFile)
+ {
+ var text = await TryReadProtectedTextAsync(metaFile, owningFolder: null, cancellationToken);
+ if (text is null)
+ {
+ // The account's metadata can no longer be decrypted with the current protection
+ // key, so the whole account is unusable. Discard it and let the user re-add it,
+ // which regenerates fresh key material rather than leaving the picker broken.
+ if (accountsFolder is IModifiableFolder modifiableAccountsFolder)
+ await TryDeleteAsync(modifiableAccountsFolder, accountFolder, cancellationToken);
+
+ continue;
+ }
+
+ var lines = text.Split(Environment.NewLine);
+ accounts.Add(new DeviceKeyAccount
+ {
+ Id = Get(lines, 0) ?? accountFolder.Name,
+ DisplayName = Get(lines, 1),
+ ServerUrl = Get(lines, 2),
+ UserId = Get(lines, 3)
+ });
+ }
+ else
+ {
+ accounts.Add(new DeviceKeyAccount { Id = accountFolder.Name });
+ }
+ }
+
+ return accounts;
+
+ static string? Get(string[] lines, int index)
+ => index < lines.Length && !string.IsNullOrEmpty(lines[index]) ? lines[index] : null;
+ }
+
+ ///
+ public async Task SetAccountAsync(DeviceKeyAccount account, CancellationToken cancellationToken = default)
+ {
+ var accountsFolder = await _baseFolder.CreateFolderAsync(ACCOUNTS_FOLDER_NAME, false, cancellationToken);
+ if (accountsFolder is not IModifiableFolder modifiableAccountsFolder)
+ throw new InvalidOperationException("The accounts folder is not modifiable.");
+
+ var accountFolder = await modifiableAccountsFolder.CreateFolderAsync(account.Id, false, cancellationToken);
+ if (accountFolder is not IModifiableFolder modifiableAccountFolder)
+ throw new InvalidOperationException("The account folder is not modifiable.");
+
+ var metadataFile = await modifiableAccountFolder.CreateFileAsync(ACCOUNT_METADATA_FILENAME, true, cancellationToken);
+ var content = string.Join(Environment.NewLine,
+ account.Id,
+ account.DisplayName ?? string.Empty,
+ account.ServerUrl ?? string.Empty,
+ account.UserId ?? string.Empty);
+
+ await WriteProtectedTextAsync(metadataFile, content, cancellationToken);
+ }
+
+ ///
+ public async Task HasPrivateKeyAsync(string accountId, CancellationToken cancellationToken = default)
+ {
+ var accountsFolder = await _baseFolder.TryGetFolderByNameAsync(ACCOUNTS_FOLDER_NAME, cancellationToken);
+ if (accountsFolder is null)
+ return false;
+
+ var accountFolder = await accountsFolder.TryGetFolderByNameAsync(accountId, cancellationToken);
+ if (accountFolder is null || await accountFolder.TryGetFileByNameAsync(ACCOUNT_DEVICE_KEY_FILENAME, cancellationToken) is not { } file)
+ return false;
+
+ // Verify the key can still be decrypted. If the protection key no longer matches, heal by
+ // discarding the stale key and reporting "no key" so the caller re-registers a fresh device
+ // instead of hitting an undecryptable key later in GetPrivateKeyAsync.
+ var privateKey = await TryUnprotectFileAsync(file, accountFolder as IModifiableFolder, cancellationToken);
+ if (privateKey is null)
+ return false;
+
+ CryptographicOperations.ZeroMemory(privateKey);
+ return true;
+ }
+
+ ///
+ public async Task GetPrivateKeyAsync(string accountId, CancellationToken cancellationToken = default)
+ {
+ var accountsFolder = await _baseFolder.TryGetFolderByNameAsync(ACCOUNTS_FOLDER_NAME, cancellationToken);
+ if (accountsFolder is null)
+ throw new InvalidOperationException("No device key stored. Complete App Platform setup first.");
+
+ var accountFolder = await accountsFolder.TryGetFolderByNameAsync(accountId, cancellationToken);
+ if (accountFolder is null || await accountFolder.TryGetFileByNameAsync(ACCOUNT_DEVICE_KEY_FILENAME, cancellationToken) is not { } file)
+ throw new InvalidOperationException("No device key stored. Complete App Platform setup first.");
+
+ // Discards the key if it can no longer be decrypted (returns null), surfacing the standard
+ // "setup first" error so the login flow re-bootstraps the device.
+ var privateKey = await TryUnprotectFileAsync(file, accountFolder as IModifiableFolder, cancellationToken);
+ if (privateKey is null)
+ throw new InvalidOperationException("No device key stored. Complete App Platform setup first.");
+
+ return privateKey;
+ }
+
+ ///
+ public async Task StorePrivateKeyAsync(string accountId, byte[] privateKey, CancellationToken cancellationToken = default)
+ {
+ var accountsFolder = await _baseFolder.CreateFolderAsync(ACCOUNTS_FOLDER_NAME, false, cancellationToken);
+ if (accountsFolder is not IModifiableFolder modifiableAccountsFolder)
+ throw new InvalidOperationException("The accounts folder is not modifiable.");
+
+ var accountFolder = await modifiableAccountsFolder.CreateFolderAsync(accountId, false, cancellationToken);
+ if (accountFolder is not IModifiableFolder modifiableAccountFolder)
+ throw new InvalidOperationException("The account folder is not modifiable.");
+
+ var file = await modifiableAccountFolder.CreateFileAsync(ACCOUNT_DEVICE_KEY_FILENAME, true, cancellationToken);
+ var protectedBytes = await ProtectAsync(privateKey, cancellationToken);
+ await file.WriteBytesAsync(protectedBytes, cancellationToken);
+ }
+
+ ///
+ public async Task GetDeviceIdAsync(string accountId, CancellationToken cancellationToken = default)
+ {
+ var accountsFolder = await _baseFolder.TryGetFolderByNameAsync(ACCOUNTS_FOLDER_NAME, cancellationToken);
+ if (accountsFolder is null)
+ return null;
+
+ var accountFolder = await accountsFolder.TryGetFolderByNameAsync(accountId, cancellationToken);
+ if (accountFolder is null || await accountFolder.TryGetFileByNameAsync(ACCOUNT_DEVICE_ID_FILENAME, cancellationToken) is not IFile file)
+ return null;
+
+ // If the stored id can no longer be decrypted it is discarded and treated as absent.
+ var text = await TryReadProtectedTextAsync(file, accountFolder as IModifiableFolder, cancellationToken);
+ return Guid.TryParse(text, out var id) ? id : null;
+ }
+
+ ///
+ public async Task StoreDeviceIdAsync(string accountId, Guid deviceId, CancellationToken cancellationToken = default)
+ {
+ var accountsFolder = await _baseFolder.CreateFolderAsync(ACCOUNTS_FOLDER_NAME, false, cancellationToken);
+ if (accountsFolder is not IModifiableFolder modifiableAccountsFolder)
+ throw new InvalidOperationException("The accounts folder is not modifiable.");
+
+ var accountFolder = await modifiableAccountsFolder.CreateFolderAsync(accountId, false, cancellationToken);
+ if (accountFolder is not IModifiableFolder modifiableAccountFolder)
+ throw new InvalidOperationException("The account folder is not modifiable.");
+
+ var file = await modifiableAccountFolder.CreateFileAsync(ACCOUNT_DEVICE_ID_FILENAME, false, cancellationToken);
+ await WriteProtectedTextAsync(file, deviceId.ToString(), cancellationToken);
+ }
+
+ ///
+ public async Task ClearAsync(string accountId, CancellationToken cancellationToken = default)
+ {
+ var accountsFolder = await _baseFolder.TryGetFolderByNameAsync(ACCOUNTS_FOLDER_NAME, cancellationToken);
+ if (accountsFolder is not IModifiableFolder modifiableFolder)
+ return;
+
+ if (await modifiableFolder.TryGetFirstByNameAsync(accountId, cancellationToken) is { } accountFolder)
+ await modifiableFolder.DeleteAsync(accountFolder, cancellationToken);
+ }
+
+ ///
+ /// Reads and unprotects the text content of , returning
+ /// (instead of throwing) when the data can no longer be decrypted with the current protection key.
+ ///
+ /// The file to read.
+ /// The folder that contains , used to discard the
+ /// file when it is unrecoverable. Pass when the caller cleans up separately.
+ /// A that cancels this action.
+ private async Task TryReadProtectedTextAsync(IFile file, IModifiableFolder? owningFolder, CancellationToken cancellationToken)
+ {
+ var data = await TryUnprotectFileAsync(file, owningFolder, cancellationToken);
+ return data is null ? null : Encoding.UTF8.GetString(data);
+ }
+
+ ///
+ /// Reads and unprotects the raw content of , returning
+ /// (instead of throwing) when the data can no longer be decrypted. When it cannot be decrypted the
+ /// unrecoverable file is discarded so a fresh copy can be regenerated on the next write.
+ ///
+ private async Task TryUnprotectFileAsync(IFile file, IModifiableFolder? owningFolder, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var protectedBytes = await file.ReadBytesAsync(cancellationToken);
+ return await UnprotectAsync(protectedBytes, cancellationToken);
+ }
+ catch (KeyMaterialUnrecoverableException)
+ {
+ if (owningFolder is not null && file is IStorableChild child)
+ await TryDeleteAsync(owningFolder, child, cancellationToken);
+
+ return null;
+ }
+ }
+
+ private static async Task TryDeleteAsync(IModifiableFolder folder, IStorableChild item, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await folder.DeleteAsync(item, cancellationToken: cancellationToken);
+ }
+ catch (Exception)
+ {
+ // Best-effort cleanup; a lingering unrecoverable file is simply overwritten on the next write.
+ }
+ }
+
+ private async Task WriteProtectedTextAsync(IFile file, string text, CancellationToken cancellationToken)
+ {
+ var protectedBytes = await ProtectAsync(Encoding.UTF8.GetBytes(text), cancellationToken);
+ await file.WriteBytesAsync(protectedBytes, cancellationToken);
+ }
+
+ ///
+ /// Protects sensitive data before it is written to disk.
+ ///
+ /// The data to protect.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is the protected data.
+ protected abstract Task ProtectAsync(byte[] data, CancellationToken cancellationToken);
+
+ ///
+ /// Reverses , recovering the original data read from disk.
+ ///
+ /// The data to unprotect.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is the unprotected data.
+ ///
+ /// Thrown when cannot be decrypted with the current protection key.
+ /// Implementations should raise this (rather than a raw ) so callers
+ /// can discard the stale material and regenerate fresh keys instead of failing.
+ ///
+ protected abstract Task UnprotectAsync(byte[] data, CancellationToken cancellationToken);
+ }
+
+ ///
+ /// Thrown when protected key material can no longer be decrypted with the current protection key
+ /// (for example after the OS secret store was reset or the fallback store was regenerated).
+ /// Signals that the material is unrecoverable and should be discarded and regenerated rather than
+ /// treated as a fatal error.
+ ///
+ public sealed class KeyMaterialUnrecoverableException : Exception
+ {
+ public KeyMaterialUnrecoverableException(string message)
+ : base(message)
+ {
+ }
+
+ public KeyMaterialUnrecoverableException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+ }
+}
+#endif
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/ResourceLocalizationService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/ResourceLocalizationService.cs
index 1a6f17fd1..783099f5b 100644
--- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/ResourceLocalizationService.cs
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/ResourceLocalizationService.cs
@@ -41,7 +41,7 @@ public class ResourceLocalizationService : ILocalizationService
"zh-CN"
};
- protected IAppSettings AppSettings { get; } = DI.Service().AppSettings;
+ protected IAppSettings AppSettings => field ??= DI.Service().AppSettings;
protected virtual ResourceManager ResourceManager { get; }
@@ -78,6 +78,7 @@ public ResourceLocalizationService()
public virtual Task SetCultureAsync(CultureInfo cultureInfo)
{
CurrentCulture = cultureInfo;
+ CultureInfo.CurrentCulture = cultureInfo;
AppSettings.AppLanguage = cultureInfo.Name;
return AppSettings.SaveAsync();
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/SecurePropertyKeyStore.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/SecurePropertyKeyStore.cs
new file mode 100644
index 000000000..b8c7a1b9e
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/SecurePropertyKeyStore.cs
@@ -0,0 +1,110 @@
+#if APP_PLATFORM_PRESENT
+using System;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using OwlCore.Storage;
+using SecureFolderFS.Core.Cryptography.Cipher;
+using SecureFolderFS.Shared.ComponentModel;
+
+namespace SecureFolderFS.UI.ServiceImplementation
+{
+ public class SecurePropertyKeyStore : FileDeviceKeyStore
+ {
+ private const string PROTECTION_KEY_ALIAS = "app_platform_device_key_protection";
+ private const int NONCE_SIZE = 12; // AesGcm.NonceByteSizes.MaxSize
+ private const int TAG_SIZE = 16; // AesGcm.TagByteSizes.MaxSize
+ private const int KEY_SIZE = 32; // AES-256
+
+ private readonly SemaphoreSlim _keyLock = new(1, 1);
+ private readonly IPropertyStore _propertyStore;
+
+ public SecurePropertyKeyStore(IPropertyStore propertyStore, IModifiableFolder baseFolder)
+ : base(baseFolder)
+ {
+ _propertyStore = propertyStore;
+ }
+
+ ///
+ protected override async Task ProtectAsync(byte[] data, CancellationToken cancellationToken)
+ {
+ var key = await GetOrCreateProtectionKeyAsync(cancellationToken);
+
+ var nonce = RandomNumberGenerator.GetBytes(NONCE_SIZE);
+ var ciphertext = new byte[NONCE_SIZE + data.Length + TAG_SIZE];
+ var tag = new byte[TAG_SIZE];
+
+ AesGcm256.Encrypt(
+ data,
+ key,
+ nonce,
+ tag,
+ ciphertext.AsSpan(NONCE_SIZE, data.Length),
+ ReadOnlySpan.Empty);
+
+ // [nonce][ciphertext][tag]
+ nonce.AsSpan().CopyTo(ciphertext.AsSpan(0, NONCE_SIZE));
+ tag.AsSpan().CopyTo(ciphertext.AsSpan(NONCE_SIZE + data.Length, TAG_SIZE));
+
+ return ciphertext;
+ }
+
+ ///
+ protected override async Task UnprotectAsync(byte[] data, CancellationToken cancellationToken)
+ {
+ if (data.Length < NONCE_SIZE + TAG_SIZE)
+ {
+ // A truncated/malformed blob can never be recovered; treat it like a key mismatch
+ // so the caller discards it and regenerates fresh material instead of failing hard.
+ throw new KeyMaterialUnrecoverableException("The protected data is malformed or truncated.");
+ }
+
+ var key = await GetOrCreateProtectionKeyAsync(cancellationToken);
+ var plaintext = new byte[data.Length - NONCE_SIZE - TAG_SIZE];
+
+ try
+ {
+ // [nonce][ciphertext][tag]
+ AesGcm256.Decrypt(
+ data.AsSpan(NONCE_SIZE, data.Length - NONCE_SIZE - TAG_SIZE),
+ key,
+ data.AsSpan(0, NONCE_SIZE),
+ data.AsSpan(data.Length - TAG_SIZE, TAG_SIZE),
+ plaintext,
+ ReadOnlySpan.Empty
+ );
+ }
+ catch (CryptographicException ex)
+ {
+ // The stored protection key no longer matches this blob (e.g. the OS secret store was
+ // reset, or the fallback store was regenerated). Surface it as unrecoverable so the
+ // caller can drop the stale material and regenerate fresh keys instead of breaking.
+ throw new KeyMaterialUnrecoverableException(
+ "The protected data could not be decrypted with the current protection key.", ex);
+ }
+
+ return plaintext;
+ }
+
+ private async Task GetOrCreateProtectionKeyAsync(CancellationToken cancellationToken)
+ {
+ await _keyLock.WaitAsync(cancellationToken);
+ try
+ {
+ var existing = await _propertyStore.GetValueAsync(PROTECTION_KEY_ALIAS, null, cancellationToken);
+ if (!string.IsNullOrEmpty(existing))
+ return Convert.FromBase64String(existing);
+
+ var key = RandomNumberGenerator.GetBytes(KEY_SIZE);
+ await _propertyStore.SetValueAsync(PROTECTION_KEY_ALIAS, Convert.ToBase64String(key), cancellationToken);
+
+ return key;
+ }
+ finally
+ {
+ _keyLock.Release();
+ }
+ }
+ }
+}
+#endif
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/Settings/AppSettings.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/Settings/AppSettings.cs
index e545c1f8a..4426351bf 100644
--- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/Settings/AppSettings.cs
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/Settings/AppSettings.cs
@@ -15,7 +15,7 @@ public class AppSettings : SettingsModel, IAppSettings
public AppSettings(IModifiableFolder settingsFolder)
{
- SettingsDatabase = new SingleFileDatabaseModel(Constants.FileNames.APPLICATION_SETTINGS_FILENAME, settingsFolder, DoubleSerializedStreamSerializer.Instance);
+ SettingsDatabase = new SingleFileDatabaseModel(Constants.FileNames.Settings.APPLICATION_SETTINGS_FILENAME, settingsFolder, DoubleSerializedStreamSerializer.Instance);
}
///
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/Settings/UserSettings.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/Settings/UserSettings.cs
index 05fe2a583..b3ae5fa9a 100644
--- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/Settings/UserSettings.cs
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/Settings/UserSettings.cs
@@ -28,7 +28,7 @@ public class UserSettings : SettingsModel, IUserSettings
public UserSettings(IModifiableFolder settingsFolder)
{
_settingsFolder = settingsFolder;
- SettingsDatabase = new SingleFileDatabaseModel(Constants.FileNames.USER_SETTINGS_FILENAME, settingsFolder, DoubleSerializedStreamSerializer.Instance);
+ SettingsDatabase = new SingleFileDatabaseModel(Constants.FileNames.Settings.USER_SETTINGS_FILENAME, settingsFolder, DoubleSerializedStreamSerializer.Instance);
PropertyChanged += UserSettings_PropertyChanged;
}
@@ -62,6 +62,13 @@ public virtual bool ContinueOnLastVault
set => SetSetting(value);
}
+ ///
+ public virtual string? AutoUnlockVaultId
+ {
+ get => GetSetting();
+ set => SetSetting(value);
+ }
+
///
public virtual bool OpenFolderOnUnlock
{
@@ -69,6 +76,13 @@ public virtual bool OpenFolderOnUnlock
set => SetSetting(value);
}
+ ///
+ public virtual bool EnableLocalIntegrations
+ {
+ get => GetSetting(static () => false);
+ set => SetSetting(value);
+ }
+
#endregion
#region File Browser
@@ -119,13 +133,6 @@ public virtual bool LockOnSystemLock
set => SetSetting(value);
}
- ///
- public virtual bool DisableRecentAccess
- {
- get => GetSetting(static () => false);
- set => SetSetting(value);
- }
-
///
public bool EnableDeviceLink
{
@@ -166,7 +173,7 @@ public virtual async Task ExportAsync(CancellationToken cancellationToke
await SaveAsync(cancellationToken);
// Get the settings file
- var settingsFile = await _settingsFolder.TryGetFileByNameAsync(Constants.FileNames.USER_SETTINGS_FILENAME, cancellationToken) as IFile;
+ var settingsFile = await _settingsFolder.TryGetFileByNameAsync(Constants.FileNames.Settings.USER_SETTINGS_FILENAME, cancellationToken) as IFile;
if (settingsFile is null)
return Stream.Null;
@@ -175,7 +182,7 @@ public virtual async Task ExportAsync(CancellationToken cancellationToke
await using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
{
// Create an entry with the specified filename
- var entry = archive.CreateEntry(Constants.FileNames.USER_SETTINGS_FILENAME, CompressionLevel.Optimal);
+ var entry = archive.CreateEntry(Constants.FileNames.Settings.USER_SETTINGS_FILENAME, CompressionLevel.Optimal);
await using var entryStream = await entry.OpenAsync(cancellationToken);
await using var settingsStream = await settingsFile.OpenReadAsync(cancellationToken);
@@ -195,12 +202,12 @@ public virtual async Task ImportAsync(Stream dataStream, CancellationToken
await using var archive = new ZipArchive(dataStream, ZipArchiveMode.Read, leaveOpen: true);
// Find the settings file in the archive
- var entry = archive.GetEntry(Constants.FileNames.USER_SETTINGS_FILENAME);
+ var entry = archive.GetEntry(Constants.FileNames.Settings.USER_SETTINGS_FILENAME);
if (entry is null)
return false;
// Get or create the settings file
- var settingsFile = await _settingsFolder.CreateFileAsync(Constants.FileNames.USER_SETTINGS_FILENAME, true, cancellationToken);
+ var settingsFile = await _settingsFolder.CreateFileAsync(Constants.FileNames.Settings.USER_SETTINGS_FILENAME, true, cancellationToken);
// Write the imported settings
await using var entryStream = await entry.OpenAsync(cancellationToken);
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultManagerService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultManagerService.cs
index 10da9d1b3..1d69604e4 100644
--- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultManagerService.cs
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultManagerService.cs
@@ -32,6 +32,27 @@ public virtual async Task CreateAsync(IFolder vaultFolder, IKeyUsag
return await creationRoutine.FinalizeAsync(cancellationToken);
}
+ ///
+ public virtual async Task<(IDisposable UnlockContract, IKeyUsage DekKey, IKeyUsage MacKey)> CreateAppPlatformAsync(IFolder vaultFolder, VaultOptions vaultOptions, CancellationToken cancellationToken = default)
+ {
+ var routines = await VaultRoutines.CreateRoutinesAsync(vaultFolder, StreamSerializer.Instance, cancellationToken);
+ using var creationRoutine = routines.CreateAppPlatformVault();
+ await creationRoutine.InitAsync(cancellationToken);
+ creationRoutine.SetOptions(vaultOptions);
+
+ if (vaultFolder is IModifiableFolder modifiableFolder)
+ {
+ var readmeFile = await modifiableFolder.CreateFileAsync(Sdk.Constants.Vault.VAULT_README_FILENAME, true, cancellationToken);
+ await readmeFile.WriteAllTextAsync(Sdk.Constants.Vault.VAULT_README_MESSAGE, Encoding.UTF8, cancellationToken);
+ }
+
+ var unlockContract = await creationRoutine.FinalizeAsync(cancellationToken);
+ if (unlockContract is not IWrapper { Inner: { } keyPair })
+ throw new InvalidOperationException("Could not retrieve the KeyPair from the unlock contract.");
+
+ return (unlockContract, keyPair.DekKey, keyPair.MacKey);
+ }
+
///
public virtual async Task UnlockAsync(IFolder vaultFolder, IKeyUsage passkey, CancellationToken cancellationToken = default)
{
@@ -43,6 +64,17 @@ public virtual async Task UnlockAsync(IFolder vaultFolder, IKeyUsag
return await unlockRoutine.FinalizeAsync(cancellationToken);
}
+ ///
+ public virtual async Task UnlockAppPlatformAsync(IFolder vaultFolder, IKeyUsage passkey, CancellationToken cancellationToken = default)
+ {
+ var routines = await VaultRoutines.CreateRoutinesAsync(vaultFolder, StreamSerializer.Instance, cancellationToken);
+ using var unlockRoutine = routines.UnlockAppPlatformVault();
+
+ await unlockRoutine.InitAsync(cancellationToken);
+ unlockRoutine.SetCredentials(passkey);
+ return await unlockRoutine.FinalizeAsync(cancellationToken);
+ }
+
///
public virtual async Task RecoverAsync(IFolder vaultFolder, string encodedRecoveryKey, CancellationToken cancellationToken = default)
{
@@ -63,21 +95,29 @@ public virtual async Task ModifyComplementationAsync(IFolder vaultFolder, IDispo
await complementationRoutine.InitAsync(cancellationToken);
complementationRoutine.SetUnlockContract(unlockContract);
complementationRoutine.SetOptions(vaultOptions);
- complementationRoutine.SetCredentials(credentials, cancellationToken);
+ complementationRoutine.SetCredentials(credentials);
using var result = await complementationRoutine.FinalizeAsync(cancellationToken);
}
///
- public async Task RestoreAsync(IFolder vaultFolder, string encodedRecoveryKey, CancellationToken cancellationToken = default)
+ public async Task RestoreAsync(IFolder vaultFolder, string encodedRecoveryKey, Func> confirmParametersAsync, CancellationToken cancellationToken = default)
{
using var recoveryKey = KeyPair.CombineRecoveryKey(encodedRecoveryKey);
-
+
var routines = await VaultRoutines.CreateRoutinesAsync(vaultFolder, StreamSerializer.Instance, cancellationToken);
using var restoreRoutine = routines.RestoreVault();
await restoreRoutine.InitAsync(cancellationToken);
restoreRoutine.SetCredentials(recoveryKey);
-
+
+ // The detected parameters are written into a configuration signed with the vault's real MAC
+ // key, so they are put to the user before anything is committed to disk
+ var parameters = await restoreRoutine.DetectParametersAsync(cancellationToken);
+ if (!await confirmParametersAsync(parameters, cancellationToken))
+ throw new OperationCanceledException("The detected vault parameters were not confirmed.");
+
+ restoreRoutine.ConfirmParameters();
+
return await restoreRoutine.FinalizeAsync(cancellationToken);
}
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultPersistence/VaultConfigurations.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultPersistence/VaultConfigurations.cs
index 3cd416c31..a985aecea 100644
--- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultPersistence/VaultConfigurations.cs
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultPersistence/VaultConfigurations.cs
@@ -24,7 +24,7 @@ public VaultConfigurations(IModifiableFolder settingsFolder)
};
options.Converters.Add(new VaultDataSourceJsonConverter());
- SettingsDatabase = new SingleFileDatabaseModel(Constants.FileNames.SAVED_VAULTS_FILENAME, settingsFolder, new DoubleSerializedStreamSerializer(options));
+ SettingsDatabase = new SingleFileDatabaseModel(Constants.FileNames.Settings.SAVED_VAULTS_FILENAME, settingsFolder, new DoubleSerializedStreamSerializer(options));
}
///
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultPersistence/VaultWidgets.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultPersistence/VaultWidgets.cs
index 2482cb5da..6ce98dc3b 100644
--- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultPersistence/VaultWidgets.cs
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultPersistence/VaultWidgets.cs
@@ -18,7 +18,7 @@ public sealed class VaultsWidgets : SettingsModel, IVaultWidgets
public VaultsWidgets(IModifiableFolder settingsFolder)
{
- SettingsDatabase = new BatchDatabaseModel(Constants.FileNames.VAULTS_WIDGETS_FOLDERNAME, settingsFolder, StreamSerializer.Instance);
+ SettingsDatabase = new BatchDatabaseModel(Constants.FileNames.Settings.VAULTS_WIDGETS_FOLDERNAME, settingsFolder, StreamSerializer.Instance);
}
///
diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultService.cs
index f01282b19..da3a523f2 100644
--- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultService.cs
+++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultService.cs
@@ -53,7 +53,8 @@ public virtual async Task GetVaultOptionsAsync(IFolder vaultFolder
RecycleBinSize = config.RecycleBinSize,
VaultId = config.Uid,
Version = config.Version,
- AppPlatform = config.AppPlatform
+ AppPlatform = config.AppPlatform,
+ ComplementGeneration = config.ComplementGeneration
};
}
@@ -67,6 +68,7 @@ public virtual async Task GetMigratorAsync(IFolder vaultFol
{
Core.Constants.Vault.Versions.V1 => Migrators.GetMigratorV1_V2(vaultFolder, StreamSerializer.Instance),
Core.Constants.Vault.Versions.V2 => Migrators.GetMigratorV2_V3(vaultFolder, StreamSerializer.Instance),
+ Core.Constants.Vault.Versions.V3 => Migrators.GetMigratorV3_V4(vaultFolder, StreamSerializer.Instance),
_ => throw new ArgumentOutOfRangeException(nameof(configVersion.Version))
};
}
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/cs-CZ/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/cs-CZ/Resources.resx
index 275d664a5..ebb80f650 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/cs-CZ/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/cs-CZ/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/da-DK/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/da-DK/Resources.resx
index f5f5ace0c..570d33e79 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/da-DK/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/da-DK/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx
index 60509290f..95a1fac10 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx
@@ -147,6 +147,9 @@
Launch SecureFolderFS on system startup
+
+ Automatically unlock
+
Back
@@ -213,12 +216,6 @@
Data encryption
-
- Disable recently accessed list
-
-
- Temporarily disable recent files list when unlocking vaults
-
Downloading {0}%
@@ -707,6 +704,18 @@
Sort by
+
+ Ascending
+
+
+ Compress
+
+
+ Items
+
+
+ Discard
+
Item layout
@@ -782,6 +791,12 @@
Delete
+
+ Delete user
+
+
+ Administrator
+
Get info
@@ -860,6 +875,12 @@
Remove
+
+ Accounts
+
+
+ Account
+
Go to vault list
@@ -1098,7 +1119,7 @@
Parts of the file are corrupted and will be reset
- {0:plural:{} corrupted region|{} corrupted regions|{} corrupted regions}
+ {0:plural:{} corrupted region|{} corrupted regions}Data loss unpreventable
@@ -1209,7 +1230,7 @@
Are you sure you want to permanently delete {0:plural:one item|{} items}?
- The deleted {0:plural:item exceeds|{} items exceed|{} items exceed} the available space in the recycle bin. Do you want to permanently delete {0:plural:this item|{} items|{} items} instead?
+ The deleted {0:plural:item exceeds|{} items exceed} the available space in the recycle bin. Do you want to permanently delete {0:plural:this item|{} items} instead?By continuing, you agree to our
@@ -1412,7 +1433,7 @@
Desktop
-
+
This device
@@ -1488,7 +1509,7 @@
The file name uses decomposed Unicode and will be normalized.
- Name shortening
+ Name shortening thresholdImportant configuration files are corrupted and must be restored.
@@ -1496,6 +1517,573 @@
SecureFolderFS will attempt to regenerate the configuration files using information from your encrypted data. After the restore process is complete, you will need to reset your credentials.
+
+ Confirm the detected vault settings before they are applied.
+
+
+ These settings were determined by decrypting your existing data. They will be written into the rebuilt configuration and cannot be changed later without re-encrypting the vault. Continue only if they match how this vault was created.
+
+
+ No file name encryption was detected. If this vault was created with encrypted file names, abort the operation as continuing will store every file name in readable form.
+
+
+ Confirm and restore
+
+
+ SecureFolderFS App Platform manages this vault
+
+
+ Vaults
+
+
+ Devices
+
+
+ Sign out
+
+
+ Permissions
+
+
+ Permission Reference
+
+
+ Group Permissions
+
+
+ User Permissions
+
+
+ Direct Permissions
+
+
+ Search by name, email, or ID
+
+
+ No groups created yet
+
+
+ Users & Groups
+
+
+ Groups
+
+
+ Users
+
+
+ Create group
+
+
+ No groups available. Create groups from the Users & Groups page first
+
+
+ Select a group to manage members
+
+
+ Select a group
+
+
+ Edit
+
+
+ Members
+
+
+ No users found
+
+
+ Remove all
+
+
+ No vaults found
+
+
+ Select a vault to manage
+
+
+ SIEM
+
+
+ Events (30d)
+
+
+ Vault Unlocks
+
+
+ Access Grants
+
+
+ Revocations
+
+
+ Show all
+
+
+ {0:plural:{} device|{} devices}
+
+
+ {0:plural:{} member|{} members}
+
+
+ {0:plural:{} pending|{} pending}
+
+
+ Action
+
+
+ All actions
+
+
+ Timestamp
+
+
+ User
+
+
+ Details
+
+
+ Resource
+
+
+ No SIEM log entries found
+
+
+ Apply
+
+
+ Device
+
+
+ Manage account in {0}
+
+
+ Profile
+
+
+ Plan
+
+
+ Free slots
+
+
+ Used slots
+
+
+ Change Account Key passphrase
+
+
+ Update license
+
+
+ Admins (app-platform-admin role) always have all permissions implicitly. These controls only affect standard users (app-platform-user role) and groups.
+
+
+ View the Insights reporting dashboard and export user and vault insight reports. Provides read-only overview of vaults, its members, device policies, access expiry, and recovery key coverage. Also granted implicitly to holders of vaults.manage_all.
+
+
+ Manage devices across all users: revoke device registrations and enforce device policies. Without this, users can only manage their own devices.
+
+
+ View all registered devices across all users. Without this, users only see their own devices.
+
+
+ Create and manage conditional access policies (max device count, vault device whitelists). Vault ownership alone does not grant policy management — this permission is required.
+
+
+ Configure webhook providers for SIEM event forwarding. Create, edit, delete, and test webhook endpoints. Requires siem.read to access the SIEM page.
+
+
+ View all SIEM/audit logs across the platform. Without this, users only see logs for their own actions and vaults.
+
+
+ Manage all user groups regardless of ownership. Grants access to the Users & Groups navigation section.
+
+
+ Manage all vaults regardless of ownership. Bypasses owner-only restrictions on vault operations.
+
+
+ This user has the app-platform-admin role and implicitly has all permissions. Permission toggles are disabled.
+
+
+ User auto-provisioned
+
+
+ User setup
+
+
+ Account key changed
+
+
+ User keys reset
+
+
+ Key reset requested
+
+
+ Key reset approved
+
+
+ Key reset denied
+
+
+ Vault registered
+
+
+ Vault updated
+
+
+ Vault deleted
+
+
+ Vault key retrieved
+
+
+ Access granted
+
+
+ Access revoked
+
+
+ Access requested
+
+
+ Access request approved
+
+
+ Access request denied
+
+
+ Access request cancelled
+
+
+ Recovery key viewed
+
+
+ Recovery key created
+
+
+ Recovery key deleted
+
+
+ Vault recovered
+
+
+ Group created
+
+
+ Group deleted
+
+
+ Group member added
+
+
+ Group member removed
+
+
+ Permissions updated
+
+
+ Group permissions updated
+
+
+ Device registered
+
+
+ Device re-registered
+
+
+ Device deregistered
+
+
+ Webhook created
+
+
+ Webhook updated
+
+
+ Webhook deleted
+
+
+ Webhook tested
+
+
+ Compliance report exported
+
+
+ Policy created
+
+
+ Policy updated
+
+
+ Policy deleted
+
+
+ Policy setting added
+
+
+ Policy setting removed
+
+
+ License updated
+
+
+ Pull token issued
+
+
+ Filter by vault name or ID (optional)
+
+
+ a user
+
+
+ Keys provisioned for {0}
+
+
+ Granted access to {0}
+
+
+ Granted vault access
+
+
+ Revoked access from {0}
+
+
+ Revoked vault access
+
+
+ Registered a new vault
+
+
+ Updated vault settings
+
+
+ Deleted the vault
+
+
+ {0} requested access
+
+
+ Approved access request for {0}
+
+
+ Approved an access request
+
+
+ Denied an access request
+
+
+ Canceled an access request
+
+
+ Viewed a vault recovery key
+
+
+ Set a recovery key
+
+
+ Removed the recovery key
+
+
+ {0} recovered vault access
+
+
+ Created the group
+
+
+ Removed a group with {0:plural:{} member|{} members}
+
+
+ Removed the group
+
+
+ Added {0}
+
+
+ Added a member
+
+
+ Removed {0}
+
+
+ Removed a member
+
+
+ Updated permissions
+
+
+ Updated group permissions
+
+
+ Added {0}
+
+
+ Removed {0}
+
+
+ Added {0:plural:{} permission|{} permissions}
+
+
+ Removed {0:plural:{} permission|{} permissions}
+
+
+ Added {0}, removed {1} permissions
+
+
+ {0} signed in for the first time
+
+
+ {0} completed App Platform setup
+
+
+ Deleted user {0}
+
+
+ Deleted a user
+
+
+ Changed the account key passphrase
+
+
+ Reset their cryptographic keys
+
+
+ Requested an admin key reset
+
+
+ Approved a key reset request
+
+
+ Denied a key reset request
+
+
+ Registered a new device
+
+
+ Re-registered an existing device
+
+
+ Removed a device
+
+
+ Created a webhook provider
+
+
+ Updated a webhook provider
+
+
+ Deleted a webhook provider
+
+
+ Sent a webhook test event
+
+
+ Viewed the compliance report
+
+
+ Exported the compliance report as PDF
+
+
+ Created a conditional access policy
+
+
+ Updated a conditional access policy
+
+
+ Deleted a conditional access policy
+
+
+ Added policy setting {0}
+
+
+ Added a policy setting
+
+
+ Removed policy setting {0}
+
+
+ Removed a policy setting
+
+
+ Updated the license key
+
+
+ Session: {0}:{1}
+
+
+ Session expired
+
+
+ Insights
+
+
+ Recovery
+
+
+ Expiry
+
+
+ Device Policy
+
+
+ Expiry Policy
+
+
+ Show more
+
+
+ Open in Vaults
+
+
+ Select a vault to inspect
+
+
+ Name
+
+
+ Users
+
+
+ Expiry
+
+
+ Recovery
+
+
+ Membership
+
+
+ Permissions
+
+
+ Membership
+
+
+ Open in Permissions
+
+
+ Select a user to inspect
+
+
+ Last access
+
+
+ No members in this group yet
+
+
+ Revocations
+
+
+ Grant Access
+
+
+ Grant
+
+
+ Approve
+
Calculating...
@@ -1503,7 +2091,7 @@
Something went wrong. The operation could not be completed
- Couldn't complete the operation for {0:plural:one item|{} items|{} items}
+ Couldn't complete the operation for {0:plural:one item|{} items}Couldn't load the contents of this folder
@@ -1535,7 +2123,88 @@
Couldn't restore {0:plural:one item|{} items}
+
+ Remote Vaults
+
+
+ New tab
+
+
+ Add vault
+
+
+ Remove vault
+
+
+ Download
+
+
+ Use recovery key instead
+
+
+ Select a vault from the list to unlock it
+
+
+ This vault is already unlocked in another tab
+
+
+ Choose a storage provider
+
+
+ Connect to the provider
+
+
+ Choose the vault folder
+
+
+ Add this folder
+
+
+ This sign-in method isn't available in the browser. Enter the vault's recovery key to unlock it.
+
+
+ Unlock with your App Platform account
+
+
+ No vaults yet. Use "Add vault" to connect a storage provider and add an existing vault.
+
+
+ Properties
+
+
+ Download folder
+
+
+ Compressing...
+
+
+ Type
+
+
+ Location
+
Taken {0} out of {1}
+
+ Restore vault
+
+
+ Put to background
+
+
+ Reduce the app to System Tray when closing the window
+
+
+ You're now ready to use SecureFolderFS! You can customize your app experience by heading to Settings.
+
+
+ Change icon
+
+
+ This vault has been restored and has no credentials set up yet
+
+
+ Set up new credentials
+
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/es-ES/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/es-ES/Resources.resx
index 62cebe961..448700e0c 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/es-ES/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/es-ES/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/fr-FR/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/fr-FR/Resources.resx
index 7d213f652..ab856ac9e 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/fr-FR/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/fr-FR/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/he-IL/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/he-IL/Resources.resx
index 22df47db6..b7cfec414 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/he-IL/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/he-IL/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/hi-IN/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/hi-IN/Resources.resx
index 22df47db6..b7cfec414 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/hi-IN/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/hi-IN/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/id-ID/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/id-ID/Resources.resx
index 22df47db6..b7cfec414 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/id-ID/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/id-ID/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/ms-MY/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/ms-MY/Resources.resx
index efa73bb92..e78aec823 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/ms-MY/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/ms-MY/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/pl-PL/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/pl-PL/Resources.resx
index 94aba7fc3..0f8dd8c97 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/pl-PL/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/pl-PL/Resources.resx
@@ -1412,7 +1412,7 @@
Komputer
-
+
To urządzenie
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/pt-PT/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/pt-PT/Resources.resx
index 2115ea7d6..fff3fd2e7 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/pt-PT/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/pt-PT/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/tr-TR/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/tr-TR/Resources.resx
index ad7de7f56..561e0448f 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/tr-TR/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/tr-TR/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/uk-UA/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/uk-UA/Resources.resx
index 48e784260..434563243 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/uk-UA/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/uk-UA/Resources.resx
@@ -1413,7 +1413,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/zh-CN/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/zh-CN/Resources.resx
index 0298d8f97..b592798e9 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/zh-CN/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/zh-CN/Resources.resx
@@ -1412,7 +1412,7 @@
Desktop
-
+
This device
diff --git a/src/Platforms/SecureFolderFS.UI/ValueConverters/BaseTextTransformConverter.cs b/src/Platforms/SecureFolderFS.UI/ValueConverters/BaseTextTransformConverter.cs
new file mode 100644
index 000000000..980c54935
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.UI/ValueConverters/BaseTextTransformConverter.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace SecureFolderFS.UI.ValueConverters
+{
+ public abstract class BaseTextTransformConverter : BaseConverter
+ {
+ ///
+ protected override object? TryConvert(object? value, Type targetType, object? parameter)
+ {
+ if (value is not string strValue)
+ return null;
+
+ if (parameter is not string strParam)
+ return strValue;
+
+ return strParam switch
+ {
+ "uppercase" => strValue.ToUpper(),
+ "lowercase" => strValue.ToLower(),
+ "firstuppercase" => string.Concat(strValue.Substring(0, 1).ToUpper(), strValue.AsSpan(1)),
+ _ => strValue
+ };
+ }
+
+ ///
+ protected override object? TryConvertBack(object? value, Type targetType, object? parameter)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformCreationViewModel.cs b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformCreationViewModel.cs
new file mode 100644
index 000000000..a0089dd83
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformCreationViewModel.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using SecureFolderFS.Core.Cryptography.Jwe;
+using SecureFolderFS.Sdk.Enums;
+using SecureFolderFS.Sdk.EventArguments;
+using SecureFolderFS.Sdk.ViewModels.Controls.Authentication;
+using SecureFolderFS.Shared;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Models;
+using SecureFolderFS.Shared.SecureStore;
+using static SecureFolderFS.Core.Constants.Vault.Authentication;
+#if APP_PLATFORM_PRESENT
+using SecureFolderFS.Sdk.AppPlatform;
+using SecureFolderFS.Sdk.AppPlatform.Dto;
+using SecureFolderFS.Sdk.AppPlatform.Helpers;
+#endif
+
+namespace SecureFolderFS.UI.ViewModels.Authentication
+{
+ public sealed partial class AppPlatformCreationViewModel : AuthenticationViewModel, IVaultOptionsProvider, IAppPlatformVaultRegistration
+ {
+#if APP_PLATFORM_PRESENT
+ private AppPlatformClient? _client;
+#endif
+
+ [ObservableProperty] private string? _ServerUrl;
+ [ObservableProperty] private bool _IsAuthenticated;
+
+ ///
+ public override event EventHandler? StateChanged;
+
+ ///
+ public override event EventHandler? CredentialsProvided;
+
+ ///
+ public override bool CanComplement { get; } = false;
+
+ ///
+ public override AuthenticationStage Availability { get; } = AuthenticationStage.FirstStageOnly;
+
+ public AppPlatformCreationViewModel()
+ : base(AUTH_APP_PLATFORM)
+ {
+ Title = "App Platform";
+ }
+
+ ///
+ public override Task RevokeAsync(string? id, CancellationToken cancellationToken = default)
+ {
+ return Task.FromException(new NotSupportedException());
+ }
+
+ ///
+ public override Task> EnrollAsync(string id, byte[]? data, CancellationToken cancellationToken = default)
+ {
+ return Task.FromException>(new NotSupportedException());
+ }
+
+ ///
+ public override Task> AcquireAsync(string id, byte[]? data, CancellationToken cancellationToken = default)
+ {
+ return Task.FromException>(new NotSupportedException());
+ }
+
+ ///
+ protected override async Task ProvideCredentialsAsync(CancellationToken cancellationToken)
+ {
+#if APP_PLATFORM_PRESENT
+ if (string.IsNullOrWhiteSpace(ServerUrl))
+ throw new InvalidOperationException("A server URL is required.");
+
+ ServerUrl = AppPlatformEndpointGuard.NormalizeServerUrl(ServerUrl);
+ var authProvider = DI.Service();
+
+ _client?.Dispose();
+ _client = new AppPlatformClient(ServerUrl);
+
+ var authConfig = await _client.GetAuthConfigAsync(cancellationToken);
+ var accessToken = await authProvider.GetAccessTokenAsync(
+ authConfig.Authority, authConfig.ClientId, authConfig.Scopes, forceLogin: true, cancellationToken: cancellationToken);
+ _client.SetAccessToken(accessToken);
+
+ var user = await _client.GetMeAsync(cancellationToken);
+ if (!user.IsSetupComplete || string.IsNullOrWhiteSpace(user.PublicKeyJwk))
+ throw new InvalidOperationException("Complete the App Platform first-time setup before creating a vault.");
+
+ IsAuthenticated = true;
+
+ var tcs = new TaskCompletionSource();
+ CredentialsProvided?.Invoke(this, new(ManagedKey.Empty, tcs));
+ await tcs.Task;
+#else
+ return;
+#endif
+ }
+
+ ///
+ public VaultOptions AmendVaultOptions(VaultOptions options)
+ {
+ return options with
+ {
+ AppPlatform = new AppPlatformVaultOptions
+ {
+ ServerUrl = ServerUrl!
+ }
+ };
+ }
+
+ ///
+ public async Task RegisterVaultAsync(string vaultId, string? name, IKeyUsage dekKey, IKeyUsage macKey, CancellationToken cancellationToken = default)
+ {
+#if APP_PLATFORM_PRESENT
+ if (_client is null)
+ throw new InvalidOperationException("The App Platform connection has not been authenticated.");
+
+ var user = await _client.GetMeAsync(cancellationToken);
+ if (string.IsNullOrWhiteSpace(user.PublicKeyJwk))
+ throw new InvalidOperationException("The user account is not set up.");
+
+ var vaultKeyJwe = GetVaultJweKey(user, dekKey, macKey);
+ await _client.RegisterVaultAsync(vaultId, name, vaultKeyJwe, description: null, cancellationToken);
+#endif
+ }
+
+#if APP_PLATFORM_PRESENT
+ private static unsafe string GetVaultJweKey(UserInfoDto userInfoDto, IKeyUsage dekKey, IKeyUsage macKey)
+ {
+ return dekKey.UseKey(dek =>
+ {
+ fixed (byte* dekPtr = dek)
+ {
+ var state = (dekPtr: (nint)dekPtr, dekLen: dek.Length);
+ return macKey.UseKey(state, (mac, s) =>
+ {
+ var localDek = new ReadOnlySpan((byte*)s.dekPtr, s.dekLen);
+ return JweHelper.EncryptVaultKey(localDek, mac, userInfoDto.PublicKeyJwk);
+ });
+ }
+ });
+ }
+#endif
+
+ ///
+ public override void Dispose()
+ {
+#if APP_PLATFORM_PRESENT
+ _client?.Dispose();
+ _client = null;
+#endif
+ base.Dispose();
+ }
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformLoginViewModel.cs b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformLoginViewModel.cs
index fee2826bb..ab04fa4a9 100644
--- a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformLoginViewModel.cs
+++ b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformLoginViewModel.cs
@@ -1,18 +1,53 @@
using System;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using OwlCore.Storage;
+using SecureFolderFS.Core.VaultAccess;
using SecureFolderFS.Sdk.Enums;
using SecureFolderFS.Sdk.EventArguments;
+using SecureFolderFS.Sdk.Models;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Sdk.ViewModels.Controls;
using SecureFolderFS.Sdk.ViewModels.Controls.Authentication;
+using SecureFolderFS.Sdk.ViewModels.Views.Overlays;
+using SecureFolderFS.Shared;
using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Models;
+using SecureFolderFS.Shared.SecureStore;
+using static SecureFolderFS.Core.Constants.Vault.Authentication;
+#if APP_PLATFORM_PRESENT
+using SecureFolderFS.Sdk.AppPlatform;
+using SecureFolderFS.Sdk.AppPlatform.Dto;
+using SecureFolderFS.Sdk.AppPlatform.Helpers;
+using SecureFolderFS.Sdk.AppPlatform.Services;
+#endif
namespace SecureFolderFS.UI.ViewModels.Authentication
{
- public sealed partial class AppPlatformLoginViewModel : AuthenticationViewModel
+ ///
+ /// The system browser authenticates via Keycloak, decrypts the vault key
+ /// client-side, and passes the result to a localhost callback.
+ ///
+ public sealed partial class AppPlatformLoginViewModel : AuthenticationViewModel, IAsyncInitialize
{
+ private readonly IFolder _vaultFolder;
+ private string? _serverUrl;
+ private string? _vaultId;
+
+ [ObservableProperty] private AccountItemViewModel? _SelectedAccount;
+
+ ///
+ /// Gets the accounts the user can choose from when logging in, including a "new account" option.
+ ///
+ public ObservableCollection Accounts { get; } = new();
+
///
public override event EventHandler? StateChanged;
-
+
///
public override event EventHandler? CredentialsProvided;
@@ -21,10 +56,49 @@ public sealed partial class AppPlatformLoginViewModel : AuthenticationViewModel
///
public override AuthenticationStage Availability { get; } = AuthenticationStage.FirstStageOnly;
-
- public AppPlatformLoginViewModel()
- : base(Core.Constants.Vault.Authentication.AUTH_APP_PLATFORM)
+
+ public AppPlatformLoginViewModel(IFolder vaultFolder)
+ : base(AUTH_APP_PLATFORM)
+ {
+ _vaultFolder = vaultFolder;
+ Title = "App Platform";
+ }
+
+ ///
+ public async Task InitAsync(CancellationToken cancellationToken = default)
{
+#if APP_PLATFORM_PRESENT
+ var vaultReader = new VaultReader(_vaultFolder, StreamSerializer.Instance);
+ var config = await vaultReader.ReadConfigurationAsync(cancellationToken);
+ if (config.AppPlatform is null)
+ return;
+
+ _serverUrl = config.AppPlatform.ServerUrl.TrimEnd('/');
+ _vaultId = config.Uid;
+
+ Accounts.Clear();
+ var newAccountOption = new AccountItemViewModel(new AccountModel(string.Empty, "Use a new account", null, null, AUTH_APP_PLATFORM));
+ Accounts.Add(newAccountOption);
+
+ var deviceKeyStore = DI.Service();
+ var mediaService = DI.Service();
+
+ var icon = await mediaService.GetImageFromResourceAsync("AppPlatformIcon", cancellationToken);
+ var normalizedServer = AppPlatformEndpointGuard.NormalizeServerUrl(_serverUrl);
+ foreach (var account in await deviceKeyStore.GetAccountsAsync(cancellationToken))
+ {
+ // Only offer accounts that belong to this vault's server (or whose server is unknown).
+ if (account.ServerUrl is not null &&
+ !string.Equals(AppPlatformEndpointGuard.NormalizeServerUrl(account.ServerUrl), normalizedServer, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ Accounts.Add(new AccountItemViewModel(new AccountModel(account.Id, account.DisplayName ?? account.Id, account.ServerUrl, icon, AUTH_APP_PLATFORM)));
+ }
+
+ SelectedAccount = Accounts.Count > 1 ? Accounts[1] : newAccountOption;
+#else
+ await Task.CompletedTask;
+#endif
}
///
@@ -46,9 +120,127 @@ public override Task> AcquireAsync(string id, byte[]? data, C
}
///
- protected override Task ProvideCredentialsAsync(CancellationToken cancellationToken)
+ protected override async Task ProvideCredentialsAsync(CancellationToken cancellationToken)
{
- return Task.CompletedTask;
+#if APP_PLATFORM_PRESENT
+ try
+ {
+ await ProvideCredentialsNativeAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // The user canceled the in-progress browser sign-in (e.g. closed the browser).
+ // Treat it as a no-op so the command resets and the Authenticate button re-enables.
+ }
+#else
+ await Task.FromException(new NotSupportedException("App Platform authentication requires the SecureFolderFS.Sdk.AppPlatform project."));
+#endif
+ }
+
+#if APP_PLATFORM_PRESENT
+ ///
+ /// Native flow: OIDC auth via system browser, then decrypt the vault key.
+ /// The user may pick an existing account (reusing its device key) or set up a new one,
+ /// in which case the Account Key passphrase bootstraps a fresh device key chain.
+ ///
+ private async Task ProvideCredentialsNativeAsync(CancellationToken cancellationToken)
+ {
+ // Ensure configuration is loaded even if InitAsync was skipped.
+ if (_serverUrl is null || _vaultId is null)
+ await InitAsync(cancellationToken);
+
+ var serverUrl = _serverUrl ?? throw new InvalidOperationException("Vault is not configured for App Platform.");
+ var vaultId = _vaultId!;
+
+ var authProvider = DI.Service();
+ var deviceKeyStore = DI.Service();
+
+ // Resolve the picker selection up-front. A null/empty selection means "use a new account".
+ var selectedAccountId = SelectedAccount?.Id;
+ var isNewAccount = string.IsNullOrEmpty(selectedAccountId);
+
+ using var client = new AppPlatformClient(serverUrl);
+ var authConfig = await client.GetAuthConfigAsync(cancellationToken);
+
+ // For a new account, force a fresh Keycloak login so the user can pick a different identity
+ // instead of silently reusing the existing SSO session.
+ var accessToken = await authProvider.GetAccessTokenAsync(
+ authConfig.Authority, authConfig.ClientId, authConfig.Scopes, forceLogin: isNewAccount, cancellationToken: cancellationToken);
+ client.SetAccessToken(accessToken);
+
+ UserInfoDto? user = null;
+ string accountId;
+
+ if (!isNewAccount)
+ {
+ accountId = selectedAccountId!;
+ }
+ else
+ {
+ // "Use a new account": resolve the signed-in identity first, so re-authenticating as
+ // an already-known user reuses that account instead of creating a duplicate.
+ user = await client.GetMeAsync(cancellationToken);
+ var normalizedServer = AppPlatformEndpointGuard.NormalizeServerUrl(serverUrl);
+ var existing = (await deviceKeyStore.GetAccountsAsync(cancellationToken)).FirstOrDefault(a =>
+ a.UserId == user.Id &&
+ (a.ServerUrl is null ||
+ string.Equals(AppPlatformEndpointGuard.NormalizeServerUrl(a.ServerUrl), normalizedServer, StringComparison.OrdinalIgnoreCase)));
+
+ accountId = existing?.Id ?? Guid.NewGuid().ToString();
+ }
+
+ var keyManager = new AppPlatformKeyManager(deviceKeyStore, client, authProvider, accountId);
+
+ // Bootstrap a device key for this account if it doesn't have one yet.
+ if (!await deviceKeyStore.HasPrivateKeyAsync(accountId, cancellationToken))
+ {
+ StateChanged?.Invoke(this, EventArgs.Empty);
+
+ var overlayService = DI.Service();
+ var overlay = new DeviceSetupOverlayViewModel();
+ var result = await overlayService.ShowAsync(overlay);
+
+ // User requested an account key reset instead of providing a passphrase
+ if (overlay.ResetRequested)
+ {
+ await client.RequestKeyResetAsync(cancellationToken);
+ throw new OperationCanceledException(
+ "Account key reset requested. An administrator must approve your request before you can set up this device again.");
+ }
+
+ if (!result.Successful || string.IsNullOrEmpty(overlay.Passphrase))
+ throw new OperationCanceledException("Account Key passphrase is required to set up this device.");
+
+ var deviceName = Environment.MachineName;
+ await keyManager.BootstrapDeviceAsync(deviceName, overlay.Passphrase, cancellationToken);
+
+ // Persist account metadata so it can be reused (and managed) next time.
+ user ??= await client.GetMeAsync(cancellationToken);
+ var displayName = user.Email ?? user.DisplayName ?? user.Id;
+ await deviceKeyStore.SetAccountAsync(
+ new DeviceKeyAccount { Id = accountId, DisplayName = displayName, ServerUrl = serverUrl, UserId = user.Id },
+ cancellationToken);
+ }
+
+ var (dekKey, macKey) = await keyManager.DecryptVaultKeyAsync(vaultId, cancellationToken);
+
+ var combined = new byte[dekKey.Length + macKey.Length];
+ try
+ {
+ Array.Copy(dekKey, 0, combined, 0, dekKey.Length);
+ Array.Copy(macKey, 0, combined, dekKey.Length, macKey.Length);
+
+ var key = ManagedKey.TakeOwnership(combined);
+ var tcs = new TaskCompletionSource();
+ CredentialsProvided?.Invoke(this, new(key, tcs));
+ await tcs.Task;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dekKey);
+ CryptographicOperations.ZeroMemory(macKey);
+ }
}
+#endif
}
}
diff --git a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs
index 5b55c1604..9d1106d39 100644
--- a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs
@@ -8,17 +8,16 @@
using Windows.ApplicationModel.Activation;
using Windows.Storage;
using CommunityToolkit.Mvvm.Messaging;
-using H.NotifyIcon;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
using Microsoft.Windows.AppLifecycle;
using OwlCore.Storage;
using SecureFolderFS.Sdk.AppModels;
using SecureFolderFS.Sdk.DataModels;
using SecureFolderFS.Sdk.Messages;
using SecureFolderFS.Sdk.Services;
-using SecureFolderFS.Sdk.ViewModels;
using SecureFolderFS.Sdk.ViewModels.Views.Host;
using SecureFolderFS.Sdk.ViewModels.Views.Root;
using SecureFolderFS.Shared;
@@ -40,7 +39,10 @@
using SecureFolderFS.Uno.Platforms.Desktop.Helpers;
#else
using Microsoft.UI;
-using Microsoft.UI.Xaml.Media;
+using SecureFolderFS.Sdk.ViewModels;
+#endif
+#if !__UNO_SKIA_MACOS__
+using H.NotifyIcon;
#endif
namespace SecureFolderFS.Uno
@@ -62,7 +64,12 @@ public partial class App : Application
///
/// Gets a task that completes when the main window has finished initializing.
///
- public TaskCompletionSource MainWindowInitialized { get; } = new();
+ ///
+ /// Continuations must not run inline. Awaiters of this task open additional windows, and running them
+ /// synchronously from the code that completes the task would do so while the main window is still
+ /// initializing, which crashes the macOS Skia host.
+ ///
+ public TaskCompletionSource MainWindowInitialized { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public BaseLifecycleHelper ApplicationLifecycle { get; } =
#if WINDOWS
@@ -156,20 +163,28 @@ await SafetyHelpers.NoFailureAsync(async () =>
// Check if the app was launched via file activation (shortcut file)
var isShortcutActivation = IsShortcutFileActivation(Program.InitialActivationArgs);
var isUriActivation = IsUriActivation(Program.InitialActivationArgs);
+ var isStartupActivation = IsStartupActivation(Program.InitialActivationArgs);
// Activate MainWindow (required for initialization)
MainWindow.Activate();
- // If launched via shortcut file, hide the main window immediately
- if (isShortcutActivation || isUriActivation)
+ // If launched via shortcut file or on system startup, hide the main window immediately
+ if (isShortcutActivation || isUriActivation || isStartupActivation)
MainWindow.Hide(enableEfficiencyMode: false);
+ // Show the auto-unlock vault prompt, unless another activation already presents vault UI
+ if (!isShortcutActivation && !isUriActivation)
+ _ = ShowAutoUnlockVaultAsync();
+
// Process initial file activation if the app was launched via file association
if (Program.InitialActivationArgs is { } initialArgs)
await OnActivatedAsync(initialArgs);
#else
// Activate MainWindow
MainWindow.Activate();
+
+ // Show the auto-unlock vault prompt
+ _ = ShowAutoUnlockVaultAsync();
#endif
}
@@ -197,6 +212,19 @@ private static bool IsUriActivation(AppActivationArguments? args)
{
return args is { Kind: ExtendedActivationKind.Protocol, Data: IProtocolActivatedEventArgs };
}
+
+ ///
+ /// Checks if the app was launched on system startup, in which case it should start in the background (System Tray).
+ ///
+ private static bool IsStartupActivation(AppActivationArguments? args)
+ {
+ // Packaged apps are launched through the StartupTask registration
+ if (args is { Kind: ExtendedActivationKind.StartupTask })
+ return true;
+
+ // Unpackaged auto start is registered in the Run registry key with a command-line argument
+ return Environment.GetCommandLineArgs().Contains(UI.Constants.AUTOSTART_ARGUMENT, StringComparer.OrdinalIgnoreCase);
+ }
#endif
///
@@ -258,6 +286,9 @@ await MainWindowSynchronizationContext.PostOrExecuteAsync(async () =>
if (MainViewModel.RootNavigationService.CurrentView is not MainHostViewModel mainHostViewModel)
return;
+ // Creating a window while the main window is still running its first layout/render pass
+ // segfaults the macOS Skia host, so this must only ever run once the main window has settled
+ // (see the remarks on MainWindowInitialized).
var window = new Window();
window.Closed += PreviewWindow_Closed;
@@ -293,6 +324,22 @@ static void PreviewWindow_Closed(object sender, WindowEventArgs args)
}
}
+ ///
+ /// Shows the unlock prompt (vault preview window) for the vault marked for automatic unlocking, if any.
+ ///
+ private async Task ShowAutoUnlockVaultAsync()
+ {
+ // Wait for initialization so that the settings and the vault list are loaded
+ await MainWindowInitialized.Task;
+
+ var settingsService = DI.Service();
+ var autoUnlockVaultId = settingsService.UserSettings.AutoUnlockVaultId;
+ if (string.IsNullOrEmpty(autoUnlockVaultId))
+ return;
+
+ await HandleVaultPreviewActivationAsync(autoUnlockVaultId);
+ }
+
private async Task HandleVaultLockActivationAsync(string persistableId)
{
if (MainViewModel is null)
@@ -351,14 +398,12 @@ private static void EnsureEarlyWindow(Window window, string title)
// Set icon
appWindow.SetIcon(Path.Combine(Package.Current.InstalledLocation.Path, Constants.FileNames.ICON_ASSET_PATH));
#endif
-#if WINDOWS
- // Set backdrop
- window.SystemBackdrop = new MicaBackdrop();
-#endif
-
// Set title
appWindow.Title = title;
+ // Set backdrop
+ window.SystemBackdrop = new MicaBackdrop();
+
// Extend title bar
var titleBar = window.Content switch
{
diff --git a/src/Platforms/SecureFolderFS.Uno/DataModels/VaultDeviceLinkDataModel.cs b/src/Platforms/SecureFolderFS.Uno/DataModels/VaultDeviceLinkDataModel.cs
index 2d349d62e..8313b2b8f 100644
--- a/src/Platforms/SecureFolderFS.Uno/DataModels/VaultDeviceLinkDataModel.cs
+++ b/src/Platforms/SecureFolderFS.Uno/DataModels/VaultDeviceLinkDataModel.cs
@@ -38,11 +38,19 @@ public sealed record VaultDeviceLinkDataModel : VaultChallengeDataModel
public required string? MobileDeviceType { get; set; }
///
- /// The expected HMAC result from mobile (Base64).
- /// Used to verify the mobile device has the correct HMAC key.
+ /// The channel binding secret folded into every authentication session's channel key.
+ /// Only a device holding the credential's HMAC key can reproduce it. It is domain-separated
+ /// from the vault key contribution, so its presence at rest reveals no vault key material.
///
- [JsonPropertyName("expectedHmac")]
- public required byte[] ExpectedHmac { get; init; }
+ [JsonPropertyName("bindingSecret")]
+ public required byte[] BindingSecret { get; init; }
+
+ ///
+ /// SHA-256 hash of the vault key contribution returned by the mobile device.
+ /// Used to verify authentication responses; the contribution itself is never persisted.
+ ///
+ [JsonPropertyName("keyVerifier")]
+ public required byte[] KeyVerifier { get; init; }
///
/// When the pairing was established.
@@ -54,6 +62,6 @@ public sealed record VaultDeviceLinkDataModel : VaultChallengeDataModel
/// Protocol version used during pairing.
///
[JsonPropertyName("protocolVersion")]
- public int ProtocolVersion { get; init; } = 4;
+ public int ProtocolVersion { get; init; } = Sdk.DeviceLink.Constants.PROTOCOL_VERSION;
}
}
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/ApiConsentDialog.xaml b/src/Platforms/SecureFolderFS.Uno/Dialogs/ApiConsentDialog.xaml
new file mode 100644
index 000000000..8eeb6708c
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/ApiConsentDialog.xaml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/ApiConsentDialog.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Dialogs/ApiConsentDialog.xaml.cs
new file mode 100644
index 000000000..eced5a862
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/ApiConsentDialog.xaml.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.UI.Xaml.Controls;
+using SecureFolderFS.Sdk.ViewModels.Views.Overlays;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Extensions;
+using SecureFolderFS.UI.Utils;
+using SecureFolderFS.Uno.Extensions;
+
+namespace SecureFolderFS.Uno.Dialogs
+{
+ public sealed partial class ApiConsentDialog : ContentDialog, IOverlayControl
+ {
+ public ApiConsentOverlayViewModel? ViewModel
+ {
+ get => DataContext.TryCast();
+ set => DataContext = value;
+ }
+
+ ///
+ /// Gets the severity used for the identity notice.
+ ///
+ public InfoBarSeverity IdentitySeverity => ViewModel?.IsIdentityVerified == true
+ ? InfoBarSeverity.Success
+ : InfoBarSeverity.Warning;
+
+ public ApiConsentDialog()
+ {
+ InitializeComponent();
+ }
+
+ ///
+ public new async Task ShowAsync() => (await base.ShowAsync()).ParseOverlayOption();
+
+ ///
+ public void SetView(IViewable viewable) => ViewModel = (ApiConsentOverlayViewModel)viewable;
+
+ ///
+ public Task HideAsync()
+ {
+ Hide();
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/DeviceSetupDialog.xaml b/src/Platforms/SecureFolderFS.Uno/Dialogs/DeviceSetupDialog.xaml
new file mode 100644
index 000000000..3025e71e3
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/DeviceSetupDialog.xaml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/DeviceSetupDialog.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Dialogs/DeviceSetupDialog.xaml.cs
new file mode 100644
index 000000000..69c84a953
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/DeviceSetupDialog.xaml.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using SecureFolderFS.Sdk.ViewModels.Views.Overlays;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Extensions;
+using SecureFolderFS.UI.Utils;
+using SecureFolderFS.Uno.Extensions;
+
+namespace SecureFolderFS.Uno.Dialogs
+{
+ public sealed partial class DeviceSetupDialog : ContentDialog, IOverlayControl
+ {
+ public DeviceSetupOverlayViewModel? ViewModel
+ {
+ get => DataContext.TryCast();
+ set => DataContext = value;
+ }
+
+ public DeviceSetupDialog()
+ {
+ InitializeComponent();
+ }
+
+ ///
+ public new async Task ShowAsync()
+ {
+ var result = await base.ShowAsync();
+
+ if (result == ContentDialogResult.Primary && ViewModel is not null)
+ ViewModel.Passphrase = PassphraseBox.Password;
+
+ return result.ParseOverlayOption();
+ }
+
+ private void ForgotPassphraseLink_Click(object sender, RoutedEventArgs e)
+ {
+ FlyoutBase.ShowAttachedFlyout(ForgotPassphraseLink);
+ }
+
+ private void ConfirmReset_Click(object sender, RoutedEventArgs e)
+ {
+ if (ViewModel is null)
+ return;
+
+ ResetFlyout.Hide();
+ ViewModel.ResetRequested = true;
+ Hide();
+ }
+
+ ///
+ public void SetView(IViewable viewable) => ViewModel = (DeviceSetupOverlayViewModel)viewable;
+
+ ///
+ public Task HideAsync()
+ {
+ Hide();
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/MigrationDialog.xaml b/src/Platforms/SecureFolderFS.Uno/Dialogs/MigrationDialog.xaml
index bbf745fca..3e1b511ac 100644
--- a/src/Platforms/SecureFolderFS.Uno/Dialogs/MigrationDialog.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/MigrationDialog.xaml
@@ -9,7 +9,6 @@
xmlns:ts="using:SecureFolderFS.Uno.TemplateSelectors"
xmlns:uc="using:SecureFolderFS.Uno.UserControls"
xmlns:uc2="using:SecureFolderFS.Uno.UserControls.Migration"
- xmlns:vc="using:SecureFolderFS.Uno.ValueConverters"
xmlns:vm="using:SecureFolderFS.Sdk.ViewModels.Controls.Authentication"
x:Name="ThisDialog"
Title="{x:Bind ViewModel.Title, Mode=OneWay}"
@@ -39,6 +38,14 @@
VaultFolder="{x:Bind VaultFolder, Mode=OneWay}"
VaultName="{x:Bind Title, Mode=OneWay}" />
+
+
+
@@ -47,7 +54,10 @@
HorizontalAlignment="Center"
Content="{x:Bind ViewModel.MigrationViewModel, Mode=OneWay}">
-
+
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/MigrationDialog.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Dialogs/MigrationDialog.xaml.cs
index 0b002e453..296162a6d 100644
--- a/src/Platforms/SecureFolderFS.Uno/Dialogs/MigrationDialog.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/MigrationDialog.xaml.cs
@@ -62,7 +62,7 @@ private void ViewModel_StateChanged(object? sender, EventArgs e)
{
if (args.Result.Exception is CryptographicException)
{
- if (AuthenticationView.ContentTemplateRoot is not IProgress reporter)
+ if (AuthenticationView.GetContentControlRoot() is not IProgress reporter)
return;
reporter.Report(args.Result);
@@ -84,7 +84,7 @@ private async void MigrationDialog_PrimaryButtonClick(ContentDialog sender, Cont
if (ViewModel is null)
return;
- if (AuthenticationView.ContentTemplateRoot is not IMigratorControl migratorControl)
+ if (AuthenticationView.GetContentControlRoot() is not IMigratorControl migratorControl)
return;
await migratorControl.ContinueAsync();
@@ -101,7 +101,7 @@ private void MigrationDialog_Closing(ContentDialog sender, ContentDialogClosingE
if (ViewModel is not null)
ViewModel.StateChanged += ViewModel_StateChanged;
- if (AuthenticationView.ContentTemplateRoot is IDisposable disposable)
+ if (AuthenticationView.GetContentControlRoot() is IDisposable disposable)
disposable.Dispose();
}
}
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/RestorationDialog.xaml b/src/Platforms/SecureFolderFS.Uno/Dialogs/RestorationDialog.xaml
index c4166a4c0..3deae673c 100644
--- a/src/Platforms/SecureFolderFS.Uno/Dialogs/RestorationDialog.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/RestorationDialog.xaml
@@ -20,7 +20,7 @@
mc:Ignorable="d">
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/RestorationDialog.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Dialogs/RestorationDialog.xaml.cs
index af74f3a53..ffe3a7a2a 100644
--- a/src/Platforms/SecureFolderFS.Uno/Dialogs/RestorationDialog.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/RestorationDialog.xaml.cs
@@ -2,6 +2,7 @@
using System.Threading.Tasks;
using Microsoft.UI.Xaml.Controls;
using SecureFolderFS.Sdk.Enums;
+using SecureFolderFS.Sdk.Extensions;
using SecureFolderFS.Sdk.ViewModels.Views.Overlays;
using SecureFolderFS.Shared.ComponentModel;
using SecureFolderFS.Shared.Extensions;
@@ -57,7 +58,13 @@ private async void ContentDialog_PrimaryButtonClick(ContentDialog sender, Conten
var result = await ViewModel.RestoreAsync();
if (!result.Successful)
+ {
+ // The first pass stops to show the detected parameters
+ if (ViewModel.IsAwaitingConfirmation)
+ PrimaryButtonText = "ConfirmAndRestore".ToLocalized();
+
return;
+ }
_tcs.TrySetResult(result);
Hide();
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/SettingsDialog.xaml b/src/Platforms/SecureFolderFS.Uno/Dialogs/SettingsDialog.xaml
index 3ba01e842..804cc928b 100644
--- a/src/Platforms/SecureFolderFS.Uno/Dialogs/SettingsDialog.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/SettingsDialog.xaml
@@ -60,12 +60,22 @@
+
+
+
+
+
+
+
+ Tag="4">
diff --git a/src/Platforms/SecureFolderFS.Uno/Dialogs/SettingsDialog.xaml.cs b/src/Platforms/SecureFolderFS.Uno/Dialogs/SettingsDialog.xaml.cs
index 63aaa8fbd..8a6d62e02 100644
--- a/src/Platforms/SecureFolderFS.Uno/Dialogs/SettingsDialog.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Dialogs/SettingsDialog.xaml.cs
@@ -52,7 +52,8 @@ private IViewDesignation GetViewForTag(int tag)
0 => ViewModel?.NavigationService.TryGetView() ?? new(),
1 => ViewModel?.NavigationService.TryGetView() ?? new(),
2 => ViewModel?.NavigationService.TryGetView() ?? new(),
- 3 => ViewModel?.NavigationService.TryGetView() ?? new(),
+ 3 => ViewModel?.NavigationService.TryGetView() ?? new(),
+ 4 => ViewModel?.NavigationService.TryGetView() ?? new(),
_ => new GeneralSettingsViewModel()
};
}
@@ -75,7 +76,9 @@ private async Task NavigateToTagAsync(int tag)
private async void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
{
- var tag = Convert.ToInt32((args.SelectedItem as NavigationViewItem)?.Tag);
+ if (!int.TryParse((args.SelectedItem as NavigationViewItem)?.Tag?.ToString(), out var tag))
+ tag = 0;
+
await NavigateToTagAsync(tag);
}
diff --git a/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.Linux.cs b/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.Linux.cs
new file mode 100644
index 000000000..a5d6c3364
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.Linux.cs
@@ -0,0 +1,43 @@
+#if __UNO_SKIA_X11__
+using System;
+
+namespace SecureFolderFS.Uno.Helpers
+{
+ internal static partial class SoundPlayerHelper
+ {
+ // Candidate players in descending order of preference
+ private static readonly string[][] _linuxPlayers =
+ [
+ ["paplay"],
+ ["pw-play"],
+ ["aplay", "-q"],
+ ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet"]
+ ];
+
+ ///
+ private static TimeSpan PlatformStartupLatency => TimeSpan.FromMilliseconds(150d);
+
+ private static void PreparePlatform(byte[] bytes)
+ {
+ // The players all address the sound by path
+ PrepareTempSound(bytes);
+ }
+
+ private static void PlayPlatform()
+ {
+ string? path;
+ lock (_lock)
+ path = _preparedPath;
+
+ if (path is not null)
+ StartPlayerProcess(path, _linuxPlayers);
+ }
+
+ private static void StopPlatform()
+ {
+ StopPlayerProcess();
+ }
+ }
+}
+
+#endif
diff --git a/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.MacOS.cs b/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.MacOS.cs
new file mode 100644
index 000000000..f2edfaa1d
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.MacOS.cs
@@ -0,0 +1,158 @@
+#if __UNO_SKIA_MACOS__
+using System;
+using System.IO;
+using System.Text;
+using SecureFolderFS.Shared.Helpers;
+using static SecureFolderFS.Uno.PInvoke.UnsafeNative;
+
+namespace SecureFolderFS.Uno.Helpers
+{
+ internal static partial class SoundPlayerHelper
+ {
+ // Only used if the sound cannot be registered; afplay is always present on macOS
+ private static readonly string[][] _fallbackPlayers = [["afplay"]];
+ private static uint _preparedSoundId;
+
+ ///
+ ///
+ /// Measured at 18-60ms once registered and primed, against ~385ms for a fresh afplay each time.
+ ///
+ private static TimeSpan PlatformStartupLatency => TimeSpan.FromMilliseconds(60d);
+
+ private static void PreparePlatform(byte[] bytes)
+ {
+ // AudioToolbox addresses sounds by URL, so the asset has to exist on disk
+ var path = PrepareTempSound(bytes);
+ var soundId = RegisterSystemSound(path);
+
+ uint previousSoundId;
+ lock (_lock)
+ {
+ previousSoundId = _preparedSoundId;
+ _preparedSoundId = soundId;
+ }
+
+ if (previousSoundId != 0)
+ _ = AudioServicesDisposeSystemSoundID(previousSoundId);
+
+ if (soundId != 0)
+ PrimeOutputDevice();
+ }
+
+ private static void PlayPlatform()
+ {
+ uint soundId;
+ string? path;
+ lock (_lock)
+ {
+ soundId = _preparedSoundId;
+ path = _preparedPath;
+ }
+
+ if (soundId != 0)
+ {
+ AudioServicesPlaySystemSound(soundId);
+ return;
+ }
+
+ // Registration failed
+ if (path is not null)
+ StartPlayerProcess(path, _fallbackPlayers);
+ }
+
+ private static void StopPlatform()
+ {
+ StopPlayerProcess();
+ }
+
+ ///
+ /// Registers a WAV file with the macOS system sound server, returning 0 if it does not take it.
+ ///
+ private static uint RegisterSystemSound(string path)
+ {
+ var cfPath = IntPtr.Zero;
+ var cfUrl = IntPtr.Zero;
+ try
+ {
+ cfPath = CFStringCreateWithCString(IntPtr.Zero, path, CF_STRING_ENCODING_UTF8);
+ if (cfPath == IntPtr.Zero)
+ return 0;
+
+ cfUrl = CFURLCreateWithFileSystemPath(IntPtr.Zero, cfPath, CF_URL_POSIX_PATH_STYLE, false);
+ if (cfUrl == IntPtr.Zero)
+ return 0;
+
+ return AudioServicesCreateSystemSoundID(cfUrl, out var soundId) == 0 ? soundId : 0;
+ }
+ catch (Exception)
+ {
+ return 0;
+ }
+ finally
+ {
+ if (cfUrl != IntPtr.Zero)
+ CFRelease(cfUrl);
+
+ if (cfPath != IntPtr.Zero)
+ CFRelease(cfPath);
+ }
+ }
+
+ ///
+ /// Plays a fraction of a second of silence, to get the output device open.
+ ///
+ private static void PrimeOutputDevice()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"sffs-prime-{Guid.NewGuid():N}.wav");
+ try
+ {
+ File.WriteAllBytes(path, CreateSilentWav(0.05d));
+
+ var soundId = RegisterSystemSound(path);
+ if (soundId == 0)
+ return;
+
+ AudioServicesPlaySystemSound(soundId);
+ }
+ catch (Exception)
+ {
+ // Do nothing
+ }
+ finally
+ {
+ SafetyHelpers.NoFailure(() => File.Delete(path));
+ }
+ }
+
+ ///
+ /// Builds a mono 16-bit WAV of the given length containing nothing but silence.
+ ///
+ private static byte[] CreateSilentWav(double seconds)
+ {
+ const int sampleRate = 44100;
+ var dataSize = (int)(sampleRate * seconds) * sizeof(short);
+
+ using var buffer = new MemoryStream(44 + dataSize);
+ using (var writer = new BinaryWriter(buffer, Encoding.ASCII, leaveOpen: true))
+ {
+ writer.Write("RIFF"u8);
+ writer.Write(36 + dataSize);
+ writer.Write("WAVE"u8);
+ writer.Write("fmt "u8);
+ writer.Write(16); // PCM header length
+ writer.Write((short)1); // PCM
+ writer.Write((short)1); // mono
+ writer.Write(sampleRate);
+ writer.Write(sampleRate * sizeof(short));
+ writer.Write((short)sizeof(short)); // block align
+ writer.Write((short)16); // bits per sample
+ writer.Write("data"u8);
+ writer.Write(dataSize);
+ writer.Write(new byte[dataSize]);
+ }
+
+ return buffer.ToArray();
+ }
+ }
+}
+#endif
diff --git a/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.Windows.cs b/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.Windows.cs
new file mode 100644
index 000000000..4905e6e9b
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.Windows.cs
@@ -0,0 +1,61 @@
+#if WINDOWS
+using System;
+using System.Runtime.InteropServices;
+using static SecureFolderFS.Uno.PInvoke.UnsafeNative;
+
+namespace SecureFolderFS.Uno.Helpers
+{
+ internal static partial class SoundPlayerHelper
+ {
+ private static GCHandle _pinnedSound;
+ private static byte[]? _preparedBytes;
+
+ ///
+ private static TimeSpan PlatformStartupLatency => TimeSpan.Zero;
+
+ private static void PreparePlatform(byte[] bytes)
+ {
+ lock (_lock)
+ {
+ _preparedBytes = bytes;
+ }
+ }
+
+ private static void PlayPlatform()
+ {
+ lock (_lock)
+ {
+ if (_preparedBytes is not { } bytes)
+ return;
+
+ // Under SND_ASYNC winmm reads the buffer for the whole duration of playback,
+ // so it has to stay pinned until the sound is replaced or stopped
+ PlaySound(IntPtr.Zero, IntPtr.Zero, SND_PURGE);
+ ReleasePinnedSound();
+
+ _pinnedSound = GCHandle.Alloc(bytes, GCHandleType.Pinned);
+ PlaySound(_pinnedSound.AddrOfPinnedObject(), IntPtr.Zero, SND_MEMORY | SND_ASYNC | SND_NODEFAULT);
+ }
+ }
+
+ private static void StopPlatform()
+ {
+ lock (_lock)
+ {
+ PlaySound(IntPtr.Zero, IntPtr.Zero, SND_PURGE);
+ ReleasePinnedSound();
+ }
+ }
+
+ private static void ReleasePinnedSound()
+ {
+ if (!_pinnedSound.IsAllocated)
+ return;
+
+ _pinnedSound.Free();
+ _pinnedSound = default;
+ }
+ }
+}
+
+#endif
diff --git a/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.cs b/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.cs
new file mode 100644
index 000000000..32fe59c10
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Helpers/SoundPlayerHelper.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Shared.Helpers;
+using SecureFolderFS.UI.Utils;
+
+namespace SecureFolderFS.Uno.Helpers
+{
+ ///
+ /// Plays short WAV assets embedded in SecureFolderFS.UI through whatever the host OS provides natively.
+ ///
+ internal static partial class SoundPlayerHelper
+ {
+ private static readonly Lock _lock = new();
+
+ private static Task? _prepareTask;
+ private static string? _preparedResource;
+ private static string? _preparedPath;
+ private static Process? _playerProcess;
+
+ ///
+ /// Gets roughly how long the platform takes to get from a play call to its first audible sample,
+ /// assuming has already completed.
+ ///
+ public static TimeSpan StartupLatency => PlatformStartupLatency;
+
+ ///
+ /// Extracts an embedded WAV asset and gets the platform ready to play it.
+ ///
+ /// The manifest resource name of the WAV asset within SecureFolderFS.UI.
+ /// A that represents the asynchronous operation.
+ public static Task PrepareAsync(string resourceName)
+ {
+ lock (_lock)
+ {
+ if (_prepareTask is not null && _preparedResource == resourceName)
+ return _prepareTask;
+
+ _preparedResource = resourceName;
+ _prepareTask = Task.Run(() => Prepare(resourceName));
+
+ return _prepareTask;
+ }
+ }
+
+ ///
+ /// Plays an embedded WAV asset so that its first sample is audible once the returned task completes,
+ /// having waited at least .
+ ///
+ /// The manifest resource name of the WAV asset within SecureFolderFS.UI.
+ /// A minimum amount of time to wait, for the caller's own purposes.
+ /// A that represents the asynchronous operation.
+ public static async Task PlayAfterAsync(string resourceName, TimeSpan settle = default)
+ {
+ await PrepareAsync(resourceName);
+
+ var lead = StartupLatency;
+ if (settle > lead)
+ await Task.Delay(settle - lead);
+
+ PlayPrepared();
+ await Task.Delay(lead);
+ }
+
+ ///
+ /// Starts playing an embedded WAV asset and returns immediately.
+ ///
+ /// The manifest resource name of the WAV asset within SecureFolderFS.UI.
+ public static void Play(string resourceName)
+ {
+ _ = PrepareAsync(resourceName).ContinueWith(_ => PlayPrepared(), TaskScheduler.Default);
+ }
+
+ ///
+ /// Stops a sound that is still playing, where the platform allows it.
+ ///
+ public static void Stop()
+ {
+ try
+ {
+ StopPlatform();
+ }
+ catch (Exception)
+ {
+ // Do nothing
+ }
+ }
+
+ ///
+ /// Reads the asset and hands it to the backend to make ready.
+ ///
+ private static void Prepare(string resourceName)
+ {
+ try
+ {
+ byte[] bytes;
+ using (var stream = typeof(IOverlayControl).Assembly.GetManifestResourceStream(resourceName))
+ {
+ if (stream is null)
+ return;
+
+ using var buffer = new MemoryStream();
+ stream.CopyTo(buffer);
+ bytes = buffer.ToArray();
+ }
+
+ PreparePlatform(bytes);
+ }
+ catch (Exception)
+ {
+ // Do nothing
+ }
+ }
+
+ ///
+ /// Starts whatever readied.
+ ///
+ private static void PlayPrepared()
+ {
+ try
+ {
+ PlayPlatform();
+ }
+ catch (Exception)
+ {
+ // Do nothing
+ }
+ }
+
+ ///
+ /// Writes the asset to a temporary file for the backends that address sounds by path.
+ ///
+ /// The path the asset was written to.
+ private static string PrepareTempSound(byte[] bytes)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"sffs-{Guid.NewGuid():N}.wav");
+ File.WriteAllBytes(path, bytes);
+
+ string? previousPath;
+ lock (_lock)
+ {
+ previousPath = _preparedPath;
+ _preparedPath = path;
+ }
+
+ if (previousPath is not null)
+ SafetyHelpers.NoFailure(() => File.Delete(previousPath));
+
+ return path;
+ }
+
+ ///
+ /// Launches the first available player from .
+ ///
+ private static bool StartPlayerProcess(string path, string[][] candidates)
+ {
+ foreach (var candidate in candidates)
+ {
+ var startInfo = new ProcessStartInfo(candidate[0])
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+
+ for (var i = 1; i < candidate.Length; i++)
+ startInfo.ArgumentList.Add(candidate[i]);
+
+ startInfo.ArgumentList.Add(path);
+
+ try
+ {
+ if (Process.Start(startInfo) is not { } process)
+ continue;
+
+ lock (_lock)
+ {
+ _playerProcess = process;
+ }
+
+ return true;
+ }
+ catch (Exception)
+ {
+ // This player is not installed - fall through to the next candidate
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Kills the player started by , if it is still running.
+ ///
+ private static void StopPlayerProcess()
+ {
+ lock (_lock)
+ {
+ if (_playerProcess is { HasExited: false } process)
+ process.Kill(entireProcessTree: true);
+ }
+ }
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Biometrics.cs b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Biometrics.cs
index 427360ea7..b5b4f4718 100644
--- a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Biometrics.cs
+++ b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Biometrics.cs
@@ -222,7 +222,7 @@ public static IntPtr CreateSecureEnclaveKey(string alias)
// kSecAttrAccessControl: access
var privateKeyAttrs = CreateCFDictionary(
new[] { _kSecAttrIsPermanent, _kSecAttrApplicationTag, _kSecAttrAccessControl },
- new[] { GetCFBooleanTrueValue(), tagData, access },
+ new[] { GetCFBooleanTrue(), tagData, access },
3);
// Build generation parameters dictionary
@@ -267,7 +267,7 @@ public static IntPtr GetPrivateKey(string alias)
var query = CreateCFDictionary(
new[] { _kSecClass, _kSecAttrApplicationTag, _kSecAttrKeyClass, _kSecAttrTokenID, _kSecReturnRef, _kSecMatchLimit, _kSecUseDataProtectionKeychain },
- new[] { _kSecClassKey, tagData, _kSecAttrKeyClassPrivate, _kSecAttrTokenIDSecureEnclave, GetCFBooleanTrueValue(), _kSecMatchLimitOne, GetCFBooleanTrueValue() },
+ new[] { _kSecClassKey, tagData, _kSecAttrKeyClassPrivate, _kSecAttrTokenIDSecureEnclave, GetCFBooleanTrue(), _kSecMatchLimitOne, GetCFBooleanTrue() },
7);
var status = SecItemCopyMatching(query, out var result);
@@ -288,7 +288,7 @@ public static void DeleteKey(string alias)
var query = CreateCFDictionary(
new[] { _kSecClass, _kSecAttrApplicationTag, _kSecAttrKeyClass, _kSecAttrTokenID, _kSecUseDataProtectionKeychain },
- new[] { _kSecClassKey, tagData, _kSecAttrKeyClassPrivate, _kSecAttrTokenIDSecureEnclave, GetCFBooleanTrueValue() },
+ new[] { _kSecClassKey, tagData, _kSecAttrKeyClassPrivate, _kSecAttrTokenIDSecureEnclave, GetCFBooleanTrue() },
5);
SecItemDelete(query);
@@ -370,13 +370,6 @@ public static byte[] Decrypt(IntPtr privateKey, byte[] ciphertext)
#region Helpers
- private static IntPtr GetCFBooleanTrueValue()
- {
- var cfLib = dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", 1);
- var ptr = dlsym(cfLib, "kCFBooleanTrue");
- return Marshal.ReadIntPtr(ptr);
- }
-
[LibraryImport("/System/Library/Frameworks/Security.framework/Security")]
private static partial IntPtr SecAccessControlCreateWithFlags(
IntPtr allocator,
diff --git a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs
index e3a0c4e99..7b4e37d3e 100644
--- a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs
+++ b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs
@@ -1,5 +1,6 @@
using System;
using System.Runtime.InteropServices;
+using System.Text;
namespace SecureFolderFS.Uno.PInvoke
{
@@ -19,6 +20,13 @@ internal static partial class UnsafeNative
public const uint SHCNF_PATHW = 0x0005;
public const uint WM_GETMINMAXINFO = 0x0024;
public const uint WM_DPICHANGED = 0x02E0;
+ public const uint SND_ASYNC = 0x0001;
+ public const uint SND_NODEFAULT = 0x0002;
+ public const uint SND_MEMORY = 0x0004;
+ public const uint SND_PURGE = 0x0040;
+
+ [DllImport("winmm.dll", CharSet = CharSet.Unicode, EntryPoint = "PlaySoundW")]
+ public static extern bool PlaySound(IntPtr data, IntPtr module, uint flags);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool GetWindowPlacement(IntPtr hWnd, ref WINDOWPLACEMENT lpwndpl);
@@ -119,19 +127,83 @@ public delegate IntPtr SUBCLASSPROC(
#endif
#if __UNO_SKIA_MACOS__
-
+ public const string LibObjc = "libobjc.dylib";
+ public const string SecurityLib = "/System/Library/Frameworks/Security.framework/Security";
+ public const string CoreFoundationLib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
+ public const string AudioToolboxLib = "/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox";
+
+ public const int ErrSecSuccess = 0;
+ public const int ErrSecDuplicateItem = -25299;
+ public const int ErrSecItemNotFound = -25300;
+ public const uint KCfStringEncodingUtf8 = 0x08000100;
public const uint CFNotificationSuspensionBehaviorDeliverImmediately = 4;
+ public const uint CF_STRING_ENCODING_UTF8 = 0x08000100;
+ public const nint CF_URL_POSIX_PATH_STYLE = 0;
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void LockCallback(IntPtr center, IntPtr observer, IntPtr name, IntPtr obj, IntPtr userInfo);
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", StringMarshalling = StringMarshalling.Utf8)]
+ #region Audio Toolbox
+
+ [LibraryImport(AudioToolboxLib)]
+ public static partial int AudioServicesCreateSystemSoundID(IntPtr fileUrl, out uint soundId);
+
+ [LibraryImport(AudioToolboxLib)]
+ public static partial void AudioServicesPlaySystemSound(uint soundId);
+
+ [LibraryImport(AudioToolboxLib)]
+ public static partial int AudioServicesDisposeSystemSoundID(uint soundId);
+
+ #endregion
+
+ #region Core Foundation
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial IntPtr CFURLCreateWithFileSystemPath(IntPtr allocator, IntPtr filePath, nint pathStyle, [MarshalAs(UnmanagedType.I1)] bool isDirectory);
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, long length);
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial IntPtr CFDictionaryCreate(
+ IntPtr allocator,
+ IntPtr keys,
+ IntPtr values,
+ long numValues,
+ IntPtr keyCallBacks,
+ IntPtr valueCallBacks);
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial IntPtr CFNumberCreate(IntPtr allocator, long theType, IntPtr valuePtr);
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial IntPtr CFStringCreateWithBytes(IntPtr allocator, byte[] bytes, nint numBytes, uint encoding, byte isExternalRepresentation);
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial IntPtr CFDataCreate(IntPtr allocator, byte[] bytes, nint length);
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial IntPtr CFDictionaryCreate(IntPtr allocator, IntPtr[] keys, IntPtr[] values, nint numValues, IntPtr keyCallBacks, IntPtr valueCallBacks);
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial void CFRelease(IntPtr cf);
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial IntPtr CFDataGetBytePtr(IntPtr theData);
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial nint CFDataGetLength(IntPtr theData);
+
+ [LibraryImport(CoreFoundationLib)]
+ public static partial IntPtr CFRetain(IntPtr cf);
+
+ [LibraryImport(CoreFoundationLib, StringMarshalling = StringMarshalling.Utf8)]
public static partial IntPtr CFNotificationCenterGetDistributedCenter();
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", StringMarshalling = StringMarshalling.Utf8)]
+ [LibraryImport(CoreFoundationLib, StringMarshalling = StringMarshalling.Utf8)]
public static partial IntPtr CFStringCreateWithCString(IntPtr allocator, string str, uint encoding);
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
+ [LibraryImport(CoreFoundationLib)]
public static partial void CFNotificationCenterAddObserver(
IntPtr center,
IntPtr observer,
@@ -140,158 +212,213 @@ public static partial void CFNotificationCenterAddObserver(
IntPtr obj,
uint suspensionBehavior);
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
+ [LibraryImport(CoreFoundationLib)]
public static partial void CFNotificationCenterRemoveObserver(
IntPtr center,
IntPtr observer,
IntPtr name,
IntPtr obj);
+
+ [LibraryImport(CoreFoundationLib, EntryPoint = "kCFBooleanTrue")]
+ public static partial IntPtr GetCFBooleanTrue();
+
+ [LibraryImport(CoreFoundationLib, EntryPoint = "kCFBooleanFalse")]
+ public static partial IntPtr GetCFBooleanFalse();
+
+ #endregion
+
+ #region Security
+
+ [LibraryImport(SecurityLib)]
+ public static partial IntPtr SecKeyCreateRandomKey(IntPtr parameters, out IntPtr error);
+
+ [LibraryImport(SecurityLib)]
+ public static partial IntPtr SecKeyCopyPublicKey(IntPtr key);
+
+ [LibraryImport(SecurityLib)]
+ public static partial IntPtr SecKeyCreateEncryptedData(IntPtr key, IntPtr algorithm, IntPtr plaintext, out IntPtr error);
+
+ [LibraryImport(SecurityLib)]
+ public static partial IntPtr SecKeyCreateDecryptedData(IntPtr key, IntPtr algorithm, IntPtr ciphertext, out IntPtr error);
+
+ [LibraryImport(SecurityLib)]
+ public static partial int SecItemAdd(IntPtr attributes, out IntPtr result);
+
+ [LibraryImport(SecurityLib)]
+ public static partial int SecItemAdd(IntPtr attributes, IntPtr result);
+
+ [LibraryImport(SecurityLib)]
+ public static partial int SecItemCopyMatching(IntPtr query, out IntPtr result);
+
+ [LibraryImport(SecurityLib)]
+ public static partial int SecItemUpdate(IntPtr query, IntPtr attributesToUpdate);
+
+ [LibraryImport(SecurityLib)]
+ public static partial int SecItemDelete(IntPtr query);
+
+ #endregion
+
+ #region Objc
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial ulong objc_msgSend_ulong(IntPtr receiver, IntPtr selector);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial void objc_msgSend_void_ulong(IntPtr receiver, IntPtr selector, ulong value);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial void objc_msgSend_void(IntPtr receiver, IntPtr selector);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial void objc_msgSend_void_long(IntPtr receiver, IntPtr selector, long value);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial void objc_msgSend_void_bool(IntPtr receiver, IntPtr selector, [MarshalAs(UnmanagedType.U1)] bool value);
- [LibraryImport("libobjc.dylib", EntryPoint = "sel_registerName", StringMarshalling = StringMarshalling.Utf8)]
+ [LibraryImport(LibObjc, EntryPoint = "sel_registerName", StringMarshalling = StringMarshalling.Utf8)]
public static partial IntPtr sel_registerName(string name);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
- public static partial IntPtr objc_msgSend_IntPtr_ulong(IntPtr receiver, IntPtr selector, ulong arg);
-
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
- public static partial CGRect objc_msgSend_CGRect(IntPtr receiver, IntPtr selector);
-
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
- public static partial void objc_msgSend_void_CGPoint(IntPtr receiver, IntPtr selector, CGPoint point);
-
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_getClass", StringMarshalling = StringMarshalling.Utf8)]
+ [LibraryImport(LibObjc, EntryPoint = "objc_getClass", StringMarshalling = StringMarshalling.Utf8)]
public static partial IntPtr objc_getClass(string className);
- [LibraryImport("libobjc.dylib", EntryPoint = "object_getClass")]
+ [LibraryImport(LibObjc, EntryPoint = "object_getClass")]
public static partial IntPtr object_getClass(IntPtr obj);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_allocateClassPair", StringMarshalling = StringMarshalling.Utf8)]
+ [LibraryImport(LibObjc, EntryPoint = "objc_allocateClassPair", StringMarshalling = StringMarshalling.Utf8)]
public static partial IntPtr objc_allocateClassPair(IntPtr superclass, string name, nint extraBytes);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_registerClassPair")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_registerClassPair")]
public static partial void objc_registerClassPair(IntPtr cls);
- [LibraryImport("libobjc.dylib", EntryPoint = "class_addMethod", StringMarshalling = StringMarshalling.Utf8)]
+ [LibraryImport(LibObjc, EntryPoint = "class_addMethod", StringMarshalling = StringMarshalling.Utf8)]
[return: MarshalAs(UnmanagedType.U1)]
public static partial bool class_addMethod(IntPtr cls, IntPtr name, IntPtr imp, string types);
- [LibraryImport("libobjc.dylib", EntryPoint = "class_replaceMethod", StringMarshalling = StringMarshalling.Utf8)]
+ [LibraryImport(LibObjc, EntryPoint = "class_replaceMethod", StringMarshalling = StringMarshalling.Utf8)]
public static partial IntPtr class_replaceMethod(IntPtr cls, IntPtr name, IntPtr imp, string types);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial long objc_msgSend_long(IntPtr receiver, IntPtr selector);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial void objc_msgSend_void_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial IntPtr objc_msgSend_IntPtr_double(IntPtr receiver, IntPtr selector, double arg);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial IntPtr objc_msgSend_IntPtr_IntPtr_IntPtr_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1, IntPtr arg2, IntPtr arg3);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial void objc_msgSend_void_CGSize(IntPtr receiver, IntPtr selector, CGSize size);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial IntPtr objc_msgSend_IntPtr(IntPtr receiver, IntPtr selector);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial IntPtr objc_msgSend_IntPtr_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
- public static partial IntPtr objc_msgSend_IntPtr_IntPtr_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1, IntPtr arg2);
-
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
[return: MarshalAs(UnmanagedType.U1)]
public static partial bool objc_msgSend_bool_long_IntPtr(IntPtr receiver, IntPtr selector, long arg1, IntPtr arg2);
- [LibraryImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
+ [LibraryImport(LibObjc, EntryPoint = "objc_msgSend")]
public static partial void objc_msgSend_void_long_IntPtr_IntPtr(IntPtr receiver, IntPtr selector, long arg1, IntPtr arg2, IntPtr arg3);
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
- public static partial void CFRelease(IntPtr cf);
-
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
- public static partial IntPtr CFRetain(IntPtr cf);
-
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
- public static partial long CFDataGetLength(IntPtr theData);
-
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
- public static partial IntPtr CFDataGetBytePtr(IntPtr theData);
-
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
- public static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, long length);
-
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
- public static partial IntPtr CFDictionaryCreate(
- IntPtr allocator,
- IntPtr keys,
- IntPtr values,
- long numValues,
- IntPtr keyCallBacks,
- IntPtr valueCallBacks);
-
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
- public static partial IntPtr CFNumberCreate(IntPtr allocator, long theType, IntPtr valuePtr);
+ #endregion
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
- public static partial IntPtr CFBooleanGetValue(IntPtr boolean);
+ public static IntPtr CfString(string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ return CFStringCreateWithBytes(IntPtr.Zero, bytes, bytes.Length, KCfStringEncodingUtf8, 0);
+ }
- [LibraryImport("/System/Library/Frameworks/Security.framework/Security")]
- public static partial IntPtr SecKeyCreateRandomKey(IntPtr parameters, out IntPtr error);
-
- [LibraryImport("/System/Library/Frameworks/Security.framework/Security")]
- public static partial IntPtr SecKeyCopyPublicKey(IntPtr key);
-
- [LibraryImport("/System/Library/Frameworks/Security.framework/Security")]
- public static partial IntPtr SecKeyCreateEncryptedData(IntPtr key, IntPtr algorithm, IntPtr plaintext, out IntPtr error);
-
- [LibraryImport("/System/Library/Frameworks/Security.framework/Security")]
- public static partial IntPtr SecKeyCreateDecryptedData(IntPtr key, IntPtr algorithm, IntPtr ciphertext, out IntPtr error);
-
- [LibraryImport("/System/Library/Frameworks/Security.framework/Security")]
- public static partial int SecItemAdd(IntPtr attributes, out IntPtr result);
-
- [LibraryImport("/System/Library/Frameworks/Security.framework/Security")]
- public static partial int SecItemCopyMatching(IntPtr query, out IntPtr result);
-
- [LibraryImport("/System/Library/Frameworks/Security.framework/Security")]
- public static partial int SecItemDelete(IntPtr query);
-
- // Well-known CFString constants from Security framework
- [LibraryImport("/System/Library/Frameworks/Security.framework/Security", EntryPoint = "kSecAttrKeyTypeECSECPrimeRandom")]
- public static partial IntPtr GetSecAttrKeyTypeECSECPrimeRandom();
-
- // Global symbol accessors for Security framework constants
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", EntryPoint = "kCFBooleanTrue")]
- public static partial IntPtr GetCFBooleanTrue();
-
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", EntryPoint = "kCFBooleanFalse")]
- public static partial IntPtr GetCFBooleanFalse();
-
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", EntryPoint = "kCFTypeDictionaryKeyCallBacks")]
- public static partial IntPtr GetCFTypeDictionaryKeyCallBacks();
-
- [LibraryImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", EntryPoint = "kCFTypeDictionaryValueCallBacks")]
- public static partial IntPtr GetCFTypeDictionaryValueCallBacks();
+ public static IntPtr CfData(byte[] bytes)
+ {
+ return CFDataCreate(IntPtr.Zero, bytes, bytes.Length);
+ }
+#endif
+
+#if !__UNO_SKIA_MACOS__ && !WINDOWS
+
+ private const string LibSecret = "libsecret-1.so.0";
+ private const string LibGlib = "libglib-2.0.so.0";
+ public const string SecretCollectionDefault = "default";
+
+ [LibraryImport(LibSecret, StringMarshalling = StringMarshalling.Utf8)]
+ public static partial int secret_password_storev_sync(IntPtr schema, IntPtr attributes, string collection, string label, string password, IntPtr cancellable, out IntPtr error);
+
+ [LibraryImport(LibSecret)]
+ public static partial IntPtr secret_password_lookupv_sync(IntPtr schema, IntPtr attributes, IntPtr cancellable, out IntPtr error);
+
+ [LibraryImport(LibSecret)]
+ public static partial int secret_password_clearv_sync(IntPtr schema, IntPtr attributes, IntPtr cancellable, out IntPtr error);
+
+ [LibraryImport(LibSecret)]
+ public static partial void secret_password_free(IntPtr password);
+
+ [LibraryImport(LibGlib)]
+ public static partial IntPtr g_hash_table_new(IntPtr hashFunc, IntPtr keyEqualFunc);
+
+ [LibraryImport(LibGlib)]
+ public static partial void g_hash_table_insert(IntPtr hashTable, IntPtr key, IntPtr value);
+
+ [LibraryImport(LibGlib)]
+ public static partial void g_hash_table_unref(IntPtr hashTable);
+
+ [LibraryImport(LibGlib)]
+ public static partial void g_error_free(IntPtr error);
+
+ public static class GlibFunctions
+ {
+ internal static readonly IntPtr StrHash;
+ internal static readonly IntPtr StrEqual;
+
+ static GlibFunctions()
+ {
+ var glib = NativeLibrary.Load(LibGlib);
+ StrHash = NativeLibrary.GetExport(glib, "g_str_hash");
+ StrEqual = NativeLibrary.GetExport(glib, "g_str_equal");
+ }
+ }
+
+ ///
+ /// Owns a GHashTable of string attributes ({"key": value} or empty) plus the unmanaged
+ /// strings inserted into it (libsecret copies what it needs during the sync calls).
+ ///
+ public readonly struct SecretAttributes : IDisposable
+ {
+ internal IntPtr Handle { get; }
+ private readonly IntPtr _keyPtr;
+ private readonly IntPtr _valuePtr;
+
+ internal SecretAttributes(string? key)
+ {
+ Handle = g_hash_table_new(GlibFunctions.StrHash, GlibFunctions.StrEqual);
+ if (key is not null)
+ {
+ _keyPtr = Marshal.StringToHGlobalAnsi("Key");
+ _valuePtr = Marshal.StringToHGlobalAnsi(key);
+ g_hash_table_insert(Handle, _keyPtr, _valuePtr);
+ }
+ else
+ {
+ _keyPtr = IntPtr.Zero;
+ _valuePtr = IntPtr.Zero;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (Handle != IntPtr.Zero)
+ g_hash_table_unref(Handle);
+ if (_keyPtr != IntPtr.Zero)
+ Marshal.FreeHGlobal(_keyPtr);
+ if (_valuePtr != IntPtr.Zero)
+ Marshal.FreeHGlobal(_valuePtr);
+ }
+ }
#endif
}
@@ -382,20 +509,90 @@ internal struct SHFOLDERCUSTOMSETTINGS
#endif
#if __UNO_SKIA_MACOS__
- [StructLayout(LayoutKind.Sequential)]
- internal struct CGPoint
+ ///
+ /// Lazily-resolved CoreFoundation/Security constants. kSec* symbols are exported CFStringRef
+ /// variables (dereference the export); the dictionary callback symbols are the structs
+ /// themselves (use the export address directly).
+ ///
+ public static class MacOsConstants
{
- public double X;
- public double Y;
+ internal static readonly IntPtr SecClass;
+ internal static readonly IntPtr SecClassGenericPassword;
+ internal static readonly IntPtr SecAttrService;
+ internal static readonly IntPtr SecAttrAccount;
+ internal static readonly IntPtr SecValueData;
+ internal static readonly IntPtr SecReturnData;
+ internal static readonly IntPtr SecMatchLimit;
+ internal static readonly IntPtr SecMatchLimitOne;
+ internal static readonly IntPtr CfBooleanTrue;
+ internal static readonly IntPtr TypeDictionaryKeyCallBacks;
+ internal static readonly IntPtr TypeDictionaryValueCallBacks;
+
+ static MacOsConstants()
+ {
+ var security = NativeLibrary.Load(UnsafeNative.SecurityLib);
+ var coreFoundation = NativeLibrary.Load(UnsafeNative.CoreFoundationLib);
+
+ SecClass = Deref(security, "kSecClass");
+ SecClassGenericPassword = Deref(security, "kSecClassGenericPassword");
+ SecAttrService = Deref(security, "kSecAttrService");
+ SecAttrAccount = Deref(security, "kSecAttrAccount");
+ SecValueData = Deref(security, "kSecValueData");
+ SecReturnData = Deref(security, "kSecReturnData");
+ SecMatchLimit = Deref(security, "kSecMatchLimit");
+ SecMatchLimitOne = Deref(security, "kSecMatchLimitOne");
+ CfBooleanTrue = Deref(coreFoundation, "kCFBooleanTrue");
+ TypeDictionaryKeyCallBacks = NativeLibrary.GetExport(coreFoundation, "kCFTypeDictionaryKeyCallBacks");
+ TypeDictionaryValueCallBacks = NativeLibrary.GetExport(coreFoundation, "kCFTypeDictionaryValueCallBacks");
+ }
+
+ private static IntPtr Deref(IntPtr library, string symbol)
+ => Marshal.ReadIntPtr(NativeLibrary.GetExport(library, symbol));
}
-
- [StructLayout(LayoutKind.Sequential)]
- internal struct CGRect
+
+ ///
+ /// Owns a CFDictionary and the CF value objects passed into it (keys are shared kSec* constants and must not be released).
+ ///
+ public readonly struct CfDictionary : IDisposable
{
- public double X;
- public double Y;
- public double Width;
- public double Height;
+ internal IntPtr Handle { get; }
+ private readonly IntPtr[] _ownedValues;
+
+ internal CfDictionary(params (IntPtr key, IntPtr value)[] entries)
+ {
+ var keys = new IntPtr[entries.Length];
+ var values = new IntPtr[entries.Length];
+ var owned = new IntPtr[entries.Length];
+
+ for (var i = 0; i < entries.Length; i++)
+ {
+ keys[i] = entries[i].key;
+ values[i] = entries[i].value;
+
+ // Constants (booleans, match limits) are process-wide singletons — don't release them.
+ owned[i] = values[i] != MacOsConstants.CfBooleanTrue && values[i] != MacOsConstants.SecMatchLimitOne &&
+ values[i] != MacOsConstants.SecClassGenericPassword
+ ? values[i]
+ : IntPtr.Zero;
+ }
+
+ _ownedValues = owned;
+ Handle = UnsafeNative.CFDictionaryCreate(
+ IntPtr.Zero, keys, values, entries.Length,
+ MacOsConstants.TypeDictionaryKeyCallBacks, MacOsConstants.TypeDictionaryValueCallBacks);
+ }
+
+ public void Dispose()
+ {
+ if (Handle != IntPtr.Zero)
+ UnsafeNative.CFRelease(Handle);
+
+ foreach (var value in _ownedValues)
+ {
+ if (value != IntPtr.Zero)
+ UnsafeNative.CFRelease(value);
+ }
+ }
}
[StructLayout(LayoutKind.Sequential)]
diff --git a/src/Platforms/SecureFolderFS.Uno/Package.appxmanifest b/src/Platforms/SecureFolderFS.Uno/Package.appxmanifest
index d609f588e..3ec1488c1 100644
--- a/src/Platforms/SecureFolderFS.Uno/Package.appxmanifest
+++ b/src/Platforms/SecureFolderFS.Uno/Package.appxmanifest
@@ -4,8 +4,9 @@
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
+ xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
- IgnorableNamespaces="uap uap3 rescap">
+ IgnorableNamespaces="uap uap3 uap5 rescap">
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/SkiaLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/SkiaLifecycleHelper.cs
index 923846ef1..0b7da8d3a 100644
--- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/SkiaLifecycleHelper.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/SkiaLifecycleHelper.cs
@@ -12,7 +12,13 @@
using SecureFolderFS.UI.ServiceImplementation;
using SecureFolderFS.Uno.Extensions;
using SecureFolderFS.Uno.Platforms.Desktop.ServiceImplementation;
+using SecureFolderFS.Uno.ServiceImplementation;
using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions;
+#if APP_PLATFORM_PRESENT
+using SecureFolderFS.Sdk.AppPlatform.Helpers;
+using SecureFolderFS.Sdk.AppPlatform.Services;
+using SecureFolderFS.Shared.ComponentModel;
+#endif
namespace SecureFolderFS.Uno.Platforms.Desktop.Helpers
{
@@ -25,7 +31,7 @@ internal sealed class SkiaLifecycleHelper : BaseLifecycleHelper
///
public override Task InitAsync(CancellationToken cancellationToken = default)
{
- 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);
@@ -65,6 +71,14 @@ protected override IServiceCollection ConfigureServices(IModifiableFolder settin
.Override(AddService.AddSingleton)
.Override(AddService.AddSingleton)
.Override(AddService.AddSingleton)
+#if APP_PLATFORM_PRESENT
+ .Override(AddService.AddSingleton)
+#endif
+#if APP_PLATFORM_PRESENT && !WINDOWS
+ .AddSingleton(new SkiaPropertyStoreService(settingsFolder))
+ .AddSingleton(sp => new SecurePropertyKeyStore(sp.GetRequiredService().SecurePropertyStore, settingsFolder))
+ .AddSingleton(sp => new AppPlatformAccountProvider(sp.GetRequiredService()))
+#endif
.WithUnoServices(settingsFolder)
;
diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/LibSecretPropertyStore.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/LibSecretPropertyStore.cs
new file mode 100644
index 000000000..bb470aa9c
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/LibSecretPropertyStore.cs
@@ -0,0 +1,158 @@
+#if !__UNO_SKIA_MACOS__ && !WINDOWS
+using System;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Shared.ComponentModel;
+using static SecureFolderFS.Uno.PInvoke.UnsafeNative;
+
+namespace SecureFolderFS.Uno.Platforms.Desktop.ServiceImplementation
+{
+ ///
+ /// An backed by the freedesktop Secret Service via libsecret
+ /// (GNOME Keyring, KWallet 5.97+, etc.). Values are protected by the user's login keyring.
+ ///
+ internal sealed partial class LibSecretPropertyStore : IPropertyStore, IDisposable
+ {
+ private const string SchemaName = "com.securefolderfs.deviceKeys";
+
+ private readonly IntPtr _schema;
+
+ public LibSecretPropertyStore()
+ {
+ _schema = CreateSchema();
+ }
+
+ ///
+ /// Probes for a usable Secret Service so callers can fall back when libsecret or the
+ /// user session's keyring daemon is unavailable.
+ ///
+ internal static bool IsSupported()
+ {
+ try
+ {
+ using var store = new LibSecretPropertyStore();
+ _ = store.GetRaw("__sffs_probe__");
+ return true;
+ }
+ catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or InvalidOperationException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ public Task GetValueAsync(string key, Func? defaultValue = null, CancellationToken cancellationToken = default)
+ {
+ var raw = GetRaw(key);
+ if (raw is null)
+ return Task.FromResult(defaultValue is not null ? defaultValue() : default);
+
+ if (typeof(TValue) == typeof(string))
+ return Task.FromResult((TValue?)(object)raw);
+
+ return Task.FromResult(JsonSerializer.Deserialize(raw));
+ }
+
+ ///
+ public Task SetValueAsync(string key, TValue? value, CancellationToken cancellationToken = default)
+ {
+ var raw = value as string ?? JsonSerializer.Serialize(value);
+
+ using var attributes = new SecretAttributes(key);
+ var stored = secret_password_storev_sync(
+ _schema, attributes.Handle, SecretCollectionDefault, $"SecureFolderFS ({key})", raw, IntPtr.Zero, out var error);
+
+ ThrowOnGError(error, "store");
+ return Task.FromResult(stored != 0);
+ }
+
+ ///
+ public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
+ {
+ using var attributes = new SecretAttributes(key);
+ var cleared = secret_password_clearv_sync(_schema, attributes.Handle, IntPtr.Zero, out var error);
+
+ ThrowOnGError(error, "clear");
+ return Task.FromResult(cleared != 0);
+ }
+
+ ///
+ public Task WipeAsync(CancellationToken cancellationToken = default)
+ {
+ // An empty attribute set matches every item of the schema.
+ using var attributes = new SecretAttributes(key: null);
+ _ = secret_password_clearv_sync(_schema, attributes.Handle, IntPtr.Zero, out var error);
+
+ ThrowOnGError(error, "wipe");
+ return Task.CompletedTask;
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_schema != IntPtr.Zero)
+ Marshal.FreeHGlobal(_schema);
+ }
+
+ private string? GetRaw(string key)
+ {
+ using var attributes = new SecretAttributes(key);
+ var result = secret_password_lookupv_sync(_schema, attributes.Handle, IntPtr.Zero, out var error);
+
+ ThrowOnGError(error, "lookup");
+ if (result == IntPtr.Zero)
+ return null;
+
+ try
+ {
+ return Marshal.PtrToStringUTF8(result);
+ }
+ finally
+ {
+ secret_password_free(result);
+ }
+ }
+
+ private static void ThrowOnGError(IntPtr error, string operation)
+ {
+ if (error == IntPtr.Zero)
+ return;
+
+ // GError layout: { GQuark domain; gint code; gchar* message; }
+ var messagePtr = Marshal.ReadIntPtr(error, IntPtr.Size);
+ var message = messagePtr != IntPtr.Zero ? Marshal.PtrToStringUTF8(messagePtr) : null;
+ g_error_free(error);
+
+ throw new InvalidOperationException($"Secret Service {operation} failed: {message ?? "unknown error"}.");
+ }
+
+ ///
+ /// Builds an unmanaged SecretSchema with a single string attribute ("key").
+ /// Layout: { const gchar* name; int flags; SecretSchemaAttribute attributes[32]; ... }
+ /// where SecretSchemaAttribute is { const gchar* name; int type; }. The terminating
+ /// attribute entry has a null name. The strings and struct live for the process lifetime.
+ ///
+ private static IntPtr CreateSchema()
+ {
+ var attributeEntrySize = IntPtr.Size + IntPtr.Size; // pointer + int (padded to pointer size)
+ var headerSize = IntPtr.Size + IntPtr.Size; // name pointer + flags (padded)
+ var schema = Marshal.AllocHGlobal(headerSize + 32 * attributeEntrySize);
+
+ // Zero the whole block so unused attribute slots terminate the list.
+ for (var offset = 0; offset < headerSize + 32 * attributeEntrySize; offset += IntPtr.Size)
+ Marshal.WriteIntPtr(schema, offset, IntPtr.Zero);
+
+ Marshal.WriteIntPtr(schema, 0, Marshal.StringToHGlobalAnsi(SchemaName));
+ Marshal.WriteInt32(schema, IntPtr.Size, 0 /* SECRET_SCHEMA_NONE */);
+
+ // attributes[0] = { "key", SECRET_SCHEMA_ATTRIBUTE_STRING (0) }
+ Marshal.WriteIntPtr(schema, headerSize, Marshal.StringToHGlobalAnsi("Key"));
+ Marshal.WriteInt32(schema, headerSize + IntPtr.Size, 0);
+
+ return schema;
+ }
+ }
+}
+#endif
diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/MacOsKeychainPropertyStore.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/MacOsKeychainPropertyStore.cs
new file mode 100644
index 000000000..4938dab9d
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/MacOsKeychainPropertyStore.cs
@@ -0,0 +1,141 @@
+#if __UNO_SKIA_MACOS__
+using System;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Uno.PInvoke;
+using static SecureFolderFS.Uno.PInvoke.UnsafeNative;
+
+namespace SecureFolderFS.Uno.Platforms.Desktop.ServiceImplementation
+{
+ ///
+ /// An backed by the macOS Keychain.
+ ///
+ internal sealed class MacOsKeychainPropertyStore : IPropertyStore
+ {
+ /// Keychain service name all items are filed under.
+ private const string ServiceName = "com.securefolderfs.deviceKeys";
+
+ ///
+ /// Probes the Keychain with a harmless query so callers can fall back when the native
+ /// libraries are unavailable (should always succeed on macOS).
+ ///
+ internal static bool IsSupported()
+ {
+ try
+ {
+ _ = GetRaw("__sffs_probe__");
+ return true;
+ }
+ catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ public Task GetValueAsync(string key, Func? defaultValue = null, CancellationToken cancellationToken = default)
+ {
+ var raw = GetRaw(key);
+ if (raw is null)
+ return Task.FromResult(defaultValue is not null ? defaultValue() : default);
+
+ if (typeof(TValue) == typeof(string))
+ return Task.FromResult((TValue?)(object)raw);
+
+ return Task.FromResult(JsonSerializer.Deserialize(raw));
+ }
+
+ ///
+ public Task SetValueAsync(string key, TValue? value, CancellationToken cancellationToken = default)
+ {
+ var raw = value as string ?? JsonSerializer.Serialize(value);
+ SetRaw(key, raw);
+ return Task.FromResult(true);
+ }
+
+ ///
+ public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
+ {
+ using var query = new CfDictionary(
+ (MacOsConstants.SecClass, MacOsConstants.SecClassGenericPassword),
+ (MacOsConstants.SecAttrService, CfString(ServiceName)),
+ (MacOsConstants.SecAttrAccount, CfString(key)));
+
+ return Task.FromResult(SecItemDelete(query.Handle) == ErrSecSuccess);
+ }
+
+ ///
+ public Task WipeAsync(CancellationToken cancellationToken = default)
+ {
+ // SecItemDelete removes every item matching the query.
+ using var query = new CfDictionary(
+ (MacOsConstants.SecClass, MacOsConstants.SecClassGenericPassword),
+ (MacOsConstants.SecAttrService, CfString(ServiceName)));
+
+ _ = SecItemDelete(query.Handle);
+ return Task.CompletedTask;
+ }
+
+ private static string? GetRaw(string key)
+ {
+ using var query = new CfDictionary(
+ (MacOsConstants.SecClass, MacOsConstants.SecClassGenericPassword),
+ (MacOsConstants.SecAttrService, CfString(ServiceName)),
+ (MacOsConstants.SecAttrAccount, CfString(key)),
+ (MacOsConstants.SecReturnData, MacOsConstants.CfBooleanTrue),
+ (MacOsConstants.SecMatchLimit, MacOsConstants.SecMatchLimitOne));
+
+ var status = SecItemCopyMatching(query.Handle, out var result);
+ if (status == ErrSecItemNotFound)
+ return null;
+ if (status != ErrSecSuccess)
+ throw new InvalidOperationException($"Keychain read failed with status {status}.");
+
+ try
+ {
+ var length = (int)CFDataGetLength(result);
+ var bytes = new byte[length];
+ Marshal.Copy(CFDataGetBytePtr(result), bytes, 0, length);
+ return Encoding.UTF8.GetString(bytes);
+ }
+ finally
+ {
+ CFRelease(result);
+ }
+ }
+
+ private static void SetRaw(string key, string value)
+ {
+ var data = Encoding.UTF8.GetBytes(value);
+
+ using (var addQuery = new CfDictionary(
+ (MacOsConstants.SecClass, MacOsConstants.SecClassGenericPassword),
+ (MacOsConstants.SecAttrService, CfString(ServiceName)),
+ (MacOsConstants.SecAttrAccount, CfString(key)),
+ (MacOsConstants.SecValueData, CfData(data))))
+ {
+ var status = SecItemAdd(addQuery.Handle, IntPtr.Zero);
+ if (status == ErrSecSuccess)
+ return;
+ if (status != ErrSecDuplicateItem)
+ throw new InvalidOperationException($"Keychain write failed with status {status}.");
+ }
+
+ using var findQuery = new CfDictionary(
+ (MacOsConstants.SecClass, MacOsConstants.SecClassGenericPassword),
+ (MacOsConstants.SecAttrService, CfString(ServiceName)),
+ (MacOsConstants.SecAttrAccount, CfString(key)));
+ using var updateAttrs = new CfDictionary(
+ (MacOsConstants.SecValueData, CfData(data)));
+
+ var updateStatus = SecItemUpdate(findQuery.Handle, updateAttrs.Handle);
+ if (updateStatus != ErrSecSuccess)
+ throw new InvalidOperationException($"Keychain update failed with status {updateStatus}.");
+ }
+ }
+}
+#endif
diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaPropertyStoreService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaPropertyStoreService.cs
new file mode 100644
index 000000000..760075b43
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaPropertyStoreService.cs
@@ -0,0 +1,185 @@
+#if !WINDOWS
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using OwlCore.Storage;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Storage.Extensions;
+using SecureFolderFS.UI.ServiceImplementation;
+
+namespace SecureFolderFS.Uno.Platforms.Desktop.ServiceImplementation
+{
+ ///
+ internal sealed class SkiaPropertyStoreService : BasePropertyStoreService
+ {
+ public SkiaPropertyStoreService(IModifiableFolder settingsFolder)
+ {
+ SecurePropertyStore = CreateSecureStore(settingsFolder);
+ }
+
+ ///
+ public override IPropertyStore SecurePropertyStore { get; }
+
+ ///
+ /// Picks the strongest secret store available on this machine.
+ ///
+ private static IPropertyStore CreateSecureStore(IModifiableFolder settingsFolder)
+ {
+#if __UNO_SKIA_MACOS__
+ if (MacOsKeychainPropertyStore.IsSupported())
+ return new MacOsKeychainPropertyStore();
+#endif
+
+#if !__UNO_SKIA_MACOS__ && !WINDOWS
+ if (LibSecretPropertyStore.IsSupported())
+ return new LibSecretPropertyStore();
+#endif
+
+ return new FallbackFilePropertyStore(settingsFolder);
+ }
+ }
+
+ ///
+ /// Last-resort for systems without an OS secret store.
+ /// Values are persisted as JSON in a file, whose unix permissions are restricted to the owner
+ /// (0600). This protects against other local users but NOT against malware running as the
+ /// same user; platforms with a keyring never use this store.
+ ///
+ internal sealed class FallbackFilePropertyStore : IPropertyStore
+ {
+ private const string FileName = "secure_properties.dat";
+
+ private readonly IModifiableFolder _settingsFolder;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+ private Dictionary? _cache;
+
+ public FallbackFilePropertyStore(IModifiableFolder settingsFolder)
+ {
+ _settingsFolder = settingsFolder;
+ }
+
+ ///
+ public async Task GetValueAsync(string key, Func? defaultValue = null, CancellationToken cancellationToken = default)
+ {
+ await _lock.WaitAsync(cancellationToken);
+ try
+ {
+ var cache = await LoadAsync(cancellationToken);
+ if (!cache.TryGetValue(key, out var raw))
+ return defaultValue is not null ? defaultValue() : default;
+
+ if (typeof(TValue) == typeof(string))
+ return (TValue?)(object)raw;
+
+ return JsonSerializer.Deserialize(raw);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ ///
+ public async Task SetValueAsync(string key, TValue? value, CancellationToken cancellationToken = default)
+ {
+ await _lock.WaitAsync(cancellationToken);
+ try
+ {
+ var cache = await LoadAsync(cancellationToken);
+ cache[key] = value is string str ? str : JsonSerializer.Serialize(value);
+ await PersistAsync(cache, cancellationToken);
+
+ return true;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ ///
+ public async Task RemoveAsync(string key, CancellationToken cancellationToken = default)
+ {
+ await _lock.WaitAsync(cancellationToken);
+ try
+ {
+ var cache = await LoadAsync(cancellationToken);
+ if (!cache.Remove(key))
+ return false;
+
+ await PersistAsync(cache, cancellationToken);
+ return true;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ ///
+ public async Task WipeAsync(CancellationToken cancellationToken = default)
+ {
+ await _lock.WaitAsync(cancellationToken);
+ try
+ {
+ _cache = new Dictionary();
+ await PersistAsync(_cache, cancellationToken);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ private async Task> LoadAsync(CancellationToken cancellationToken)
+ {
+ if (_cache is not null)
+ return _cache;
+
+ var file = await _settingsFolder.TryGetFileByNameAsync(FileName, cancellationToken);
+ if (file is null)
+ return _cache = new Dictionary();
+
+ try
+ {
+ var bytes = await file.ReadBytesAsync(cancellationToken);
+ _cache = JsonSerializer.Deserialize>(Encoding.UTF8.GetString(bytes));
+ }
+ catch (JsonException)
+ {
+ _cache = null;
+ }
+
+ return _cache ??= new Dictionary();
+ }
+
+ private async Task PersistAsync(Dictionary cache, CancellationToken cancellationToken)
+ {
+ var file = await _settingsFolder.CreateFileAsync(FileName, overwrite: false, cancellationToken);
+ var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(cache));
+ await file.WriteBytesAsync(bytes, cancellationToken);
+
+ RestrictToOwner(file);
+ }
+
+ private static void RestrictToOwner(IFile file)
+ {
+ // Best effort: on locatable (filesystem) storage the storable Id is the full path.
+ try
+ {
+ if (!OperatingSystem.IsWindows() && File.Exists(file.Id))
+ File.SetUnixFileMode(file.Id, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ catch (Exception)
+ {
+ // Permissions are defense-in-depth here; failing to set them must not break persistence.
+ }
+ }
+ }
+}
+#endif
diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaSystemService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaSystemService.cs
index 299793d18..910c55ca8 100644
--- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaSystemService.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaSystemService.cs
@@ -1,5 +1,7 @@
using System;
using System.IO;
+using System.Linq;
+using System.Security;
using System.Threading;
using System.Threading.Tasks;
using OwlCore.Storage;
@@ -10,6 +12,8 @@ namespace SecureFolderFS.Uno.Platforms.Desktop.ServiceImplementation
///
internal sealed partial class SkiaSystemService : ISystemService
{
+ private const string AUTOSTART_ENTRY_ID = "org.securefolderfs.SecureFolderFS";
+
private EventHandler? _deviceLocked;
#if !__UNO_SKIA_MACOS__
@@ -37,5 +41,116 @@ public Task GetAvailableFreeSpaceAsync(IFolder storageRoot, CancellationTo
return Task.FromResult(0L);
}
}
+
+ ///
+ public Task IsAutoStartEnabledAsync(CancellationToken cancellationToken = default)
+ {
+ if (OperatingSystem.IsMacOS())
+ return Task.FromResult(File.Exists(GetMacOSLaunchAgentPath()));
+
+ if (OperatingSystem.IsLinux())
+ return Task.FromResult(File.Exists(GetLinuxAutostartEntryPath()));
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public async Task TrySetAutoStartAsync(bool isEnabled, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (OperatingSystem.IsMacOS())
+ {
+ var launchAgentPath = GetMacOSLaunchAgentPath();
+ if (!isEnabled)
+ {
+ if (File.Exists(launchAgentPath))
+ File.Delete(launchAgentPath);
+
+ return true;
+ }
+
+ var executablePath = Environment.ProcessPath;
+ if (executablePath is null)
+ return false;
+
+ // When running from an .app bundle, launch the bundle itself so macOS treats it as a regular app launch
+ var bundleIndex = executablePath.IndexOf(".app/Contents/MacOS/", StringComparison.Ordinal);
+ var programArguments = bundleIndex >= 0
+ ? new[] { "/usr/bin/open", "-a", executablePath[..(bundleIndex + ".app".Length)] }
+ : new[] { executablePath };
+
+ var argumentsXml = string.Join(Environment.NewLine, programArguments.Select(static x => $" {SecurityElement.Escape(x)}"));
+ var plistContents = $"""
+
+
+
+
+ Label
+ {AUTOSTART_ENTRY_ID}
+ ProgramArguments
+
+ {argumentsXml}
+
+ RunAtLoad
+
+
+
+ """;
+
+ _ = Directory.CreateDirectory(Path.GetDirectoryName(launchAgentPath)!);
+ await File.WriteAllTextAsync(launchAgentPath, plistContents, cancellationToken);
+ return true;
+ }
+
+ if (OperatingSystem.IsLinux())
+ {
+ var autostartEntryPath = GetLinuxAutostartEntryPath();
+ if (!isEnabled)
+ {
+ if (File.Exists(autostartEntryPath))
+ File.Delete(autostartEntryPath);
+
+ return true;
+ }
+
+ var executablePath = Environment.ProcessPath;
+ if (executablePath is null)
+ return false;
+
+ var desktopEntryContents = $"""
+ [Desktop Entry]
+ Type=Application
+ Name=SecureFolderFS
+ Exec="{executablePath}"
+ Terminal=false
+ X-GNOME-Autostart-enabled=true
+ """;
+
+ _ = Directory.CreateDirectory(Path.GetDirectoryName(autostartEntryPath)!);
+ await File.WriteAllTextAsync(autostartEntryPath, desktopEntryContents, cancellationToken);
+ return true;
+ }
+
+ return false;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ private static string GetMacOSLaunchAgentPath()
+ {
+ var userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return Path.Combine(userProfilePath, "Library", "LaunchAgents", $"{AUTOSTART_ENTRY_ID}.plist");
+ }
+
+ private static string GetLinuxAutostartEntryPath()
+ {
+ // ApplicationData resolves to $XDG_CONFIG_HOME (or ~/.config) on Linux
+ var configPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ return Path.Combine(configPath, "autostart", $"{AUTOSTART_ENTRY_ID}.desktop");
+ }
}
}
diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs
index 240864567..51baf011e 100644
--- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs
@@ -47,6 +47,11 @@ public override async IAsyncEnumerable GetCreationAsync
// Device Link
yield return new DeviceLinkCreationViewModel(vaultFolder, vaultId) { Icon = new ImageGlyph("\uE8EA") };
+#if APP_PLATFORM_PRESENT
+ // App Platform
+ yield return new AppPlatformCreationViewModel() { Icon = new ImageGlyph("\uF69B") };
+#endif
+
await Task.CompletedTask;
}
@@ -77,8 +82,10 @@ protected override async IAsyncEnumerable GetLoginAsync
// Device Link
Constants.Vault.Authentication.AUTH_DEVICE_LINK => new DeviceLinkLoginViewModel(vaultFolder, vaultId).WithInitAsync(cancellationToken),
+#if APP_PLATFORM_PRESENT
// App Platform
- Constants.Vault.Authentication.AUTH_APP_PLATFORM => new AppPlatformLoginViewModel(),
+ Constants.Vault.Authentication.AUTH_APP_PLATFORM => new AppPlatformLoginViewModel(vaultFolder).WithInitAsync(cancellationToken),
+#endif
_ => throw new NotSupportedException($"The authentication method '{item}' is not supported by the platform.")
};
diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/Helpers/WindowsLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/Helpers/WindowsLifecycleHelper.cs
index 26d1bfef5..3272a64a0 100644
--- a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/Helpers/WindowsLifecycleHelper.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/Helpers/WindowsLifecycleHelper.cs
@@ -12,7 +12,13 @@
using SecureFolderFS.Uno.Platforms.Windows.ServiceImplementation;
using Windows.Storage;
using SecureFolderFS.Shared.Extensions;
+using SecureFolderFS.Uno.ServiceImplementation;
using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions;
+#if APP_PLATFORM_PRESENT
+using SecureFolderFS.Sdk.AppPlatform.Helpers;
+using SecureFolderFS.Sdk.AppPlatform.Services;
+using SecureFolderFS.Shared.ComponentModel;
+#endif
namespace SecureFolderFS.Uno.Platforms.Windows.Helpers
{
@@ -26,7 +32,7 @@ internal sealed class WindowsLifecycleHelper : BaseLifecycleHelper
public override Task InitAsync(CancellationToken cancellationToken = default)
{
// Initialize settings
- var settingsFolderPath = Path.Combine(AppDirectory, SecureFolderFS.UI.Constants.FileNames.SETTINGS_FOLDER_NAME);
+ var settingsFolderPath = Path.Combine(AppDirectory, UI.Constants.FileNames.Settings.SETTINGS_FOLDER_NAME);
var settingsFolder = new SystemFolder(Directory.CreateDirectory(settingsFolderPath));
ConfigureServices(settingsFolder);
@@ -54,6 +60,14 @@ protected override IServiceCollection ConfigureServices(IModifiableFolder settin
.Override(AddService.AddSingleton)
.Override(AddService.AddSingleton)
.Override(AddService.AddSingleton)
+
+#if APP_PLATFORM_PRESENT
+ .Override(AddService.AddSingleton)
+#endif
+#if APP_PLATFORM_PRESENT && WINDOWS
+ .AddSingleton