diff --git a/SecureFolderFS.slnx b/SecureFolderFS.slnx index f7d1d4207..e94443dec 100644 --- a/SecureFolderFS.slnx +++ b/SecureFolderFS.slnx @@ -83,6 +83,7 @@ + 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..61a9febe1 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/Jwe/AccountKeyHelper.cs @@ -0,0 +1,77 @@ +using System; +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 + { + /// + /// Wraps an EC private key (in DER format) under a user-provided passphrase using PBES2-HS256+A128KW / A256GCM. + /// + /// 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) + { + return JWT.EncodeBytes(privateKeyBytes, passphrase, JweAlgorithm.PBES2_HS256_A128KW, JweEncryption.A256GCM); + } + + /// + /// 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) + { + return JWT.DecodeBytes(jweCompact, passphrase, JweAlgorithm.PBES2_HS256_A128KW, JweEncryption.A256GCM); + } + + /// + /// Wraps a user's EC private key for Account Key bootstrap. + /// 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..33bda9aa8 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/Jwe/EcKeyHelper.cs @@ -0,0 +1,159 @@ +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; + } + + 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('_', '/'); + 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..5afa2c25e --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/Jwe/JweHelper.cs @@ -0,0 +1,108 @@ +using System; +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. + /// + /// The plaintext bytes to encrypt. + /// The recipient's EC P-256 public key (only the public component is used). + /// A JWE compact serialization string. + public static string Encrypt(byte[] plaintext, ECDiffieHellman recipientPublicKey) + { + return JWT.EncodeBytes(plaintext, recipientPublicKey, JweAlgorithm.ECDH_ES_A256KW, JweEncryption.A256GCM); + } + + /// + /// Encrypts a byte payload for a recipient identified by their public key JWK string. + /// + /// 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); + return Encrypt(plaintext, publicKey); + } + + /// + /// 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/SecureFolderFS.Core.Cryptography.csproj b/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj index d29745225..d506b3a88 100644 --- a/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj +++ b/src/Core/SecureFolderFS.Core.Cryptography/SecureFolderFS.Core.Cryptography.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Core/SecureFolderFS.Core/Models/SecurityWrapper.cs b/src/Core/SecureFolderFS.Core/Models/SecurityWrapper.cs index 6300d8813..fdec13c68 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, IEnumerable>, IDisposable { private readonly KeyPair _keyPair; private readonly VaultConfigurationDataModel _configDataModel; @@ -21,6 +21,9 @@ internal sealed class SecurityWrapper : IWrapper, IEnumerable + KeyPair IWrapper.Inner => _keyPair; + public SecurityWrapper(KeyPair keyPair, VaultConfigurationDataModel configDataModel) { _keyPair = keyPair; 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/VaultRoutines.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs index d12da28f4..7d97f70e9 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs @@ -33,12 +33,23 @@ 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(); diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs index b1d4203d4..5ab4712d2 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs @@ -30,11 +30,7 @@ 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)); + hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.ServerUrl ?? string.Empty)); // AppPlatform.ServerUrl hmacSha256.AppendFinalData(Encoding.UTF8.GetBytes(configDataModel.AuthenticationMethod)); // AuthenticationMethod // Fill the hash to payload diff --git a/src/Platforms/Directory.Build.props b/src/Platforms/Directory.Build.props index 2e6891c2c..3436fcc4b 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 + + diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props index dba561f37..5a5b091b4 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -19,6 +19,8 @@ + + diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs index 0707c3654..2d3973852 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/ServiceImplementation/IOSVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs index 366c907f5..24fc696d1 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.UI/SecureFolderFS.UI.csproj b/src/Platforms/SecureFolderFS.UI/SecureFolderFS.UI.csproj index d20e56ab8..1177b5d5e 100644 --- a/src/Platforms/SecureFolderFS.UI/SecureFolderFS.UI.csproj +++ b/src/Platforms/SecureFolderFS.UI/SecureFolderFS.UI.csproj @@ -4,6 +4,7 @@ net10.0 disable enable + true @@ -18,6 +19,9 @@ + + + diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultManagerService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultManagerService.cs index 10da9d1b3..8beb16540 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) { 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..40fb9fbba --- /dev/null +++ b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformCreationViewModel.cs @@ -0,0 +1,151 @@ +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; +#if APP_PLATFORM_PRESENT +using SecureFolderFS.Sdk.AppPlatform; +#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(Core.Constants.Vault.Authentication.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."); + + 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, 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(AppPlatformClient.UserInfo userInfo, 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, userInfo.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..4dad47948 100644 --- a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformLoginViewModel.cs +++ b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformLoginViewModel.cs @@ -1,18 +1,34 @@ using System; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Web; +using OwlCore.Storage; +using SecureFolderFS.Core.VaultAccess; using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.EventArguments; using SecureFolderFS.Sdk.ViewModels.Controls.Authentication; using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Shared.SecureStore; namespace SecureFolderFS.UI.ViewModels.Authentication { + /// + /// 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 { + private readonly IFolder _vaultFolder; + /// public override event EventHandler? StateChanged; - + /// public override event EventHandler? CredentialsProvided; @@ -21,10 +37,12 @@ public sealed partial class AppPlatformLoginViewModel : AuthenticationViewModel /// public override AuthenticationStage Availability { get; } = AuthenticationStage.FirstStageOnly; - - public AppPlatformLoginViewModel() + + public AppPlatformLoginViewModel(IFolder vaultFolder) : base(Core.Constants.Vault.Authentication.AUTH_APP_PLATFORM) { + _vaultFolder = vaultFolder; + Title = "App Platform"; } /// @@ -46,9 +64,110 @@ public override Task> AcquireAsync(string id, byte[]? data, C } /// - protected override Task ProvideCredentialsAsync(CancellationToken cancellationToken) + protected override async Task ProvideCredentialsAsync(CancellationToken cancellationToken) + { + var vaultReader = new VaultReader(_vaultFolder, StreamSerializer.Instance); + var config = await vaultReader.ReadConfigurationAsync(cancellationToken); + + if (config.AppPlatform is null) + throw new InvalidOperationException("Vault is not configured for App Platform."); + + var serverUrl = config.AppPlatform.ServerUrl.TrimEnd('/'); + var vaultId = config.Uid; + + // Find a free localhost port for the callback + var tcpListener = new TcpListener(IPAddress.Loopback, 0); + tcpListener.Start(); + var port = ((IPEndPoint)tcpListener.LocalEndpoint).Port; + tcpListener.Stop(); + + var callbackUri = $"http://localhost:{port}/"; + + // The server renders a page that authenticates via Keycloak, decrypts the + // vault key in-browser using the user's private key (Web Crypto / IndexedDB), + // and redirects the decrypted key to our callback. + var unlockUrl = $"{serverUrl}/app/unlock" + + $"?vault={Uri.EscapeDataString(vaultId)}" + + $"&redirect={Uri.EscapeDataString(callbackUri)}"; + + using var httpListener = new HttpListener(); + httpListener.Prefixes.Add(callbackUri); + httpListener.Start(); + + try + { + Process.Start(new ProcessStartInfo(unlockUrl) { UseShellExecute = true }); + + var context = await httpListener.GetContextAsync().WaitAsync(cancellationToken); + + // The unlock page POSTs the key in the request body (not the URL) + // to avoid leaking decryption keys in browser history / Referer headers. + // Error callbacks use GET with ?error= (no sensitive data). + byte[] combined; + if (context.Request.HttpMethod == "POST") + { + using var reader = new System.IO.StreamReader(context.Request.InputStream, context.Request.ContentEncoding); + var body = await reader.ReadToEndAsync(cancellationToken); + var formData = HttpUtility.ParseQueryString(body); + var keyParam = formData.Get("key"); + + if (string.IsNullOrEmpty(keyParam)) + { + SendHtmlResponse(context, false, "No key in POST body."); + throw new InvalidOperationException("App Platform unlock failed: no key received."); + } + + SendHtmlResponse(context, true, null); + combined = Base64UrlDecode(keyParam); + } + else + { + var queryParams = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty); + var errorParam = queryParams.Get("error") ?? "Unknown error"; + SendHtmlResponse(context, false, errorParam); + throw new InvalidOperationException($"App Platform unlock failed: {errorParam}"); + } + try + { + using var key = ManagedKey.TakeOwnership(combined); + + var tcs = new TaskCompletionSource(); + CredentialsProvided?.Invoke(this, new(key, tcs)); + await tcs.Task; + } + finally + { + CryptographicOperations.ZeroMemory(combined); + } + } + finally + { + httpListener.Stop(); + } + } + + private static byte[] Base64UrlDecode(string base64Url) { - return Task.CompletedTask; + var s = base64Url.Replace('-', '+').Replace('_', '/'); + switch (s.Length % 4) + { + case 2: s += "=="; break; + case 3: s += "="; break; + } + return Convert.FromBase64String(s); + } + + private static void SendHtmlResponse(HttpListenerContext context, bool success, string? errorMessage) + { + var html = success + ? "

Vault unlocked

You can close this window.

" + : $"

Unlock failed

{WebUtility.HtmlEncode(errorMessage)}

"; + + var buffer = Encoding.UTF8.GetBytes(html); + context.Response.ContentLength64 = buffer.Length; + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.OutputStream.Write(buffer, 0, buffer.Length); + context.Response.OutputStream.Close(); } } } diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/SkiaLifecycleHelper.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/SkiaLifecycleHelper.cs index 923846ef1..7bab2a3a0 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/SkiaLifecycleHelper.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/Helpers/SkiaLifecycleHelper.cs @@ -13,6 +13,10 @@ using SecureFolderFS.Uno.Extensions; using SecureFolderFS.Uno.Platforms.Desktop.ServiceImplementation; using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions; +#if APP_PLATFORM_PRESENT +using SecureFolderFS.Sdk.AppPlatform; +using SecureFolderFS.Shared.ComponentModel; +#endif namespace SecureFolderFS.Uno.Platforms.Desktop.Helpers { @@ -65,6 +69,9 @@ protected override IServiceCollection ConfigureServices(IModifiableFolder settin .Override(AddService.AddSingleton) .Override(AddService.AddSingleton) .Override(AddService.AddSingleton) +#if APP_PLATFORM_PRESENT + .Override(AddService.AddSingleton) +#endif .WithUnoServices(settingsFolder) ; diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultCredentialsService.cs index d06020069..c8bdd9b62 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), +#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..fb2dd7843 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/Helpers/WindowsLifecycleHelper.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/Helpers/WindowsLifecycleHelper.cs @@ -13,6 +13,10 @@ using Windows.Storage; using SecureFolderFS.Shared.Extensions; using AddService = Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions; +#if APP_PLATFORM_PRESENT +using SecureFolderFS.Sdk.AppPlatform; +using SecureFolderFS.Shared.ComponentModel; +#endif namespace SecureFolderFS.Uno.Platforms.Windows.Helpers { @@ -54,6 +58,10 @@ protected override IServiceCollection ConfigureServices(IModifiableFolder settin .Override(AddService.AddSingleton) .Override(AddService.AddSingleton) .Override(AddService.AddSingleton) + +#if APP_PLATFORM_PRESENT + .Override(AddService.AddSingleton) +#endif // IIapService, IUpdateService #if DEBUG || UNPACKAGED diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ServiceImplementation/WindowsVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ServiceImplementation/WindowsVaultCredentialsService.cs index e08b855d6..a3b76bf77 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ServiceImplementation/WindowsVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ServiceImplementation/WindowsVaultCredentialsService.cs @@ -45,6 +45,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 } /// @@ -74,8 +79,10 @@ protected override async IAsyncEnumerable GetLoginAsync // Device Link Constants.Vault.Authentication.AUTH_DEVICE_LINK => new DeviceLinkLoginViewModel(vaultFolder, vaultId) { Icon = new ImageGlyph("\uE8EA") }, +#if APP_PLATFORM_PRESENT // App Platform - Constants.Vault.Authentication.AUTH_APP_PLATFORM => new AppPlatformLoginViewModel(), + Constants.Vault.Authentication.AUTH_APP_PLATFORM => new AppPlatformLoginViewModel(vaultFolder), +#endif _ => throw new NotSupportedException($"The authentication method '{item}' is not supported by the platform.") }; diff --git a/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj b/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj index 98ff4b9fb..9056065f9 100644 --- a/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj +++ b/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj @@ -57,7 +57,7 @@ - HAS_UNO_SKIA + $(DefineConstants);HAS_UNO_SKIA @@ -153,7 +153,7 @@ - UNPACKAGED + $(DefineConstants);UNPACKAGED diff --git a/src/Platforms/SecureFolderFS.Uno/TemplateSelectors/RegistrationTemplateSelector.cs b/src/Platforms/SecureFolderFS.Uno/TemplateSelectors/RegistrationTemplateSelector.cs index a9765595f..026c8aad6 100644 --- a/src/Platforms/SecureFolderFS.Uno/TemplateSelectors/RegistrationTemplateSelector.cs +++ b/src/Platforms/SecureFolderFS.Uno/TemplateSelectors/RegistrationTemplateSelector.cs @@ -25,6 +25,8 @@ internal sealed class RegistrationTemplateSelector : BaseTemplateSelector TouchIDTemplate, #endif DeviceLinkCreationViewModel => DeviceLinkTemplate, + AppPlatformCreationViewModel => AppPlatformTemplate, _ => base.SelectTemplateCore(item, container) }; } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/LoginControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/LoginControl.xaml index 6cc2f28b5..666f08c93 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/LoginControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/LoginControl.xaml @@ -144,15 +144,19 @@ + Glyph="" /> - - + + + +