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..033f5c6ea --- /dev/null +++ b/src/Core/SecureFolderFS.Core.Cryptography/Jwe/AccountKeyHelper.cs @@ -0,0 +1,136 @@ +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 + { + private const int AccountKeyPbes2Iterations = 120_000; + private const string AccountKeyAlgorithm = "PBES2-HS512+A256KW"; + private const string AccountKeyEncryption = "A256GCM"; + + /// + /// 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"] = AccountKeyPbes2Iterations + }; + + return JWT.EncodeBytes(privateKeyBytes, passphrase, JweAlgorithm.PBES2_HS512_A256KW, JweEncryption.A256GCM, extraHeaders: headers); + } + + /// + /// 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); + } + + 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), AccountKeyAlgorithm, StringComparison.Ordinal)) + { + throw new CryptographicException("Unsupported Account Key JWE algorithm."); + } + + if (!headers.TryGetValue("enc", out var enc) || + !string.Equals(Convert.ToString(enc, CultureInfo.InvariantCulture), AccountKeyEncryption, StringComparison.Ordinal)) + { + throw new CryptographicException("Unsupported Account Key JWE content encryption."); + } + + if (!headers.TryGetValue("p2c", out var p2c) || + !TryConvertToInt64(p2c, out var iterations) || + iterations != AccountKeyPbes2Iterations) + { + 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; + } + } + + /// + /// 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/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/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/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/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/ModifyComplementationRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs index c339f2fe9..cc9cd27f3 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs @@ -1,12 +1,12 @@ 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; @@ -27,6 +27,9 @@ public sealed class ModifyComplementationRoutine : IFinalizationRoutine, IContra 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,13 +51,22 @@ public void SetUnlockContract(IDisposable unlockContract) if (unlockContract is not IWrapper securityWrapper) throw new ArgumentException($"The {nameof(unlockContract)} is invalid."); - _keyPair = securityWrapper.Inner.KeyPair; + // Operate on a private copy so the caller's unlock contract is never disposed by this routine. + // This keeps the contract valid for retries if an attempt fails, and valid for the session after success. + _keyPair = securityWrapper.Inner.KeyPair.CreateCopy(); } /// public void SetOptions(VaultOptions vaultOptions) { + ArgumentNullException.ThrowIfNull(_existingConfigDataModel); + _configDataModel = VaultConfigurationDataModel.V4FromVaultOptions(vaultOptions); + + // 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. + if (!string.Equals(_configDataModel.Uid, _existingConfigDataModel.Uid, StringComparison.Ordinal)) + _configDataModel = _configDataModel with { Uid = _existingConfigDataModel.Uid }; } public void SetCredentials(ComplementationCredentials credentials, CancellationToken cancellationToken = default) @@ -107,34 +119,47 @@ private void AddComplementation( AuthenticationMethod newAuthentication) { 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[]? newComplementKey = null; byte[]? softwareEntropy = 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.")); + newComplementKey = ExportKey(RequireCredential(credentials.NewComplementCredential, "New complement credentials are required.")); + softwareEntropy = DecryptSoftwareEntropy(currentKeystoreKey); - complementSecret = DeriveComplementSecret(newPrimaryKey, GetPrimaryMethod(newAuthentication)); + complementSecret = DeriveComplementSecret(newPrimaryKey, GetPrimaryMethod(newAuthentication), generation); ReEncryptKeystore(complementSecret, softwareEntropy); - _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(complementSecret, newComplementKey, GetVaultId(), newComplementMethod)); + _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(complementSecret, newComplementKey, GetVaultId(), 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( @@ -143,51 +168,65 @@ private void ReplaceComplementation( AuthenticationMethod newAuthentication) { 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[]? newComplementKey = null; + byte[]? oldComplementSecret = null; + byte[]? newComplementSecret = null; byte[]? softwareEntropy = null; - (byte[] ComplementSecret, byte[] SoftwareEntropy) recoveredData; 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; + // 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.")); - ReEncryptKeystore(complementSecret, softwareEntropy); - _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(complementSecret, newComplementKey, GetVaultId(), newComplementMethod)); + // Recover the preserved entropy via the current (old-generation) secret... + oldComplementSecret = DeriveComplementSecret(currentPrimaryKey, GetPrimaryMethod(oldAuthentication), oldGeneration); + softwareEntropy = DecryptSoftwareEntropy(oldComplementSecret); + + // ...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, softwareEntropy); + _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(newComplementSecret, newComplementKey, GetVaultId(), 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.")); + 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)); + complementSecret = DeriveComplementSecret(currentPrimaryKey, GetPrimaryMethod(oldAuthentication), generation); softwareEntropy = DecryptSoftwareEntropy(complementSecret); ReEncryptKeystore(targetPasskey, softwareEntropy); _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 { @@ -205,27 +244,33 @@ private void ChangePrimaryAndPreserveComplementation( { var oldComplementMethod = oldAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing."); var newComplementMethod = newAuthentication.Complementation ?? throw new InvalidOperationException("Complementation method is missing."); + + // 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; var currentComplementKey = ExportKey(RequireCredential(credentials.CurrentComplementCredential, "Current complement credentials are required.")); var newPrimaryKey = ExportKey(RequireCredential(credentials.NewPrimaryCredential, "New primary credentials are required.")); byte[]? newComplementKey = null; byte[]? oldComplementSecret = null; byte[]? newComplementSecret = null; byte[]? softwareEntropy = null; - (byte[] ComplementSecret, byte[] SoftwareEntropy) recoveredData; try { - recoveredData = RecoverComplementSecretFromShare(currentComplementKey, oldComplementMethod); + var recoveredData = RecoverComplementSecretFromShare(currentComplementKey, oldComplementMethod, oldGeneration); oldComplementSecret = recoveredData.ComplementSecret; softwareEntropy = recoveredData.SoftwareEntropy; - newComplementSecret = DeriveComplementSecret(newPrimaryKey, GetPrimaryMethod(newAuthentication)); + 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)); + _sharesDataModel = CreateShares(VaultParser.WrapComplementSecret(newComplementSecret, newComplementKey, GetVaultId(), newComplementMethod, newGeneration)); + _configDataModel!.ComplementGeneration = newGeneration; _writeShares = true; } finally @@ -239,26 +284,8 @@ private void ChangePrimaryAndPreserveComplementation( } } - private (byte[] ComplementSecret, byte[] SoftwareEntropy) RecoverComplementSecretFromPrimary(byte[] currentPrimaryKey, AuthenticationMethod oldAuthentication) - { - byte[]? complementSecret = null; - byte[]? softwareEntropy = null; - - try - { - complementSecret = DeriveComplementSecret(currentPrimaryKey, GetPrimaryMethod(oldAuthentication)); - softwareEntropy = DecryptSoftwareEntropy(complementSecret); - return (complementSecret, softwareEntropy); - } - catch - { - Zero(complementSecret); - Zero(softwareEntropy); - throw; - } - } - - private (byte[] ComplementSecret, byte[] SoftwareEntropy) RecoverComplementSecretFromShare(byte[] currentKey, string complementMethod, CryptographicException? fallbackException = null) + [SkipLocalsInit] + private (byte[] ComplementSecret, byte[] SoftwareEntropy) RecoverComplementSecretFromShare(byte[] currentKey, string complementMethod, int generation) { var share = GetShare(complementMethod); byte[]? complementSecret = null; @@ -266,16 +293,10 @@ private void ChangePrimaryAndPreserveComplementation( try { - complementSecret = VaultParser.UnwrapComplementSecret(currentKey, GetVaultId(), share); + complementSecret = VaultParser.UnwrapComplementSecret(currentKey, GetVaultId(), share, generation); softwareEntropy = DecryptSoftwareEntropy(complementSecret); return (complementSecret, softwareEntropy); } - catch (CryptographicException) when (fallbackException is not null) - { - Zero(complementSecret); - Zero(softwareEntropy); - throw fallbackException; - } catch { Zero(complementSecret); @@ -284,12 +305,12 @@ private void ChangePrimaryAndPreserveComplementation( } } - private byte[] DeriveComplementSecret(byte[] passkey, string authenticationMethodId) + private byte[] DeriveComplementSecret(byte[] passkey, string authenticationMethodId, int generation) { var complementSecret = new byte[ComplementSecretLength]; try { - VaultParser.DeriveComplementKey(passkey, GetVaultId(), authenticationMethodId, complementSecret); + VaultParser.DeriveComplementKey(passkey, GetVaultId(), authenticationMethodId, generation, complementSecret); return complementSecret; } catch @@ -379,7 +400,7 @@ 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); @@ -397,8 +418,21 @@ public async Task FinalizeAsync(CancellationToken cancellationToken VaultParser.CalculateConfigMac(_configDataModel, macKey, _configDataModel.PayloadMac); }); - await _vaultWriter.WriteKeystoreAsync(_keystoreDataModel, cancellationToken); - await _vaultWriter.WriteConfigurationAsync(_configDataModel, cancellationToken); + // 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); diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs index 712b98595..a9b1e234d 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs @@ -41,7 +41,9 @@ public void SetUnlockContract(IDisposable unlockContract) if (unlockContract is not IWrapper securityWrapper) throw new ArgumentException($"The {nameof(unlockContract)} is invalid."); - _keyPair = securityWrapper.Inner.KeyPair; + // Operate on a private copy so the caller's unlock contract is never disposed by this routine, + // keeping it valid for retries after a failed attempt and for the session after a successful one. + _keyPair = securityWrapper.Inner.KeyPair.CreateCopy(); } /// diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs index 65c651192..d335610b8 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; @@ -52,6 +53,27 @@ public void SetCredentials(IKeyUsage passkey) _macKey = SecureKey.TakeOwnership(derived.macKey); } + [SkipLocalsInit] + private (byte[] dekKey, byte[] macKey) DeriveFromComplementSecret(IKeyUsage passkey, string primaryMethodId) + { + ArgumentNullException.ThrowIfNull(_configDataModel); + ArgumentNullException.ThrowIfNull(_keystoreDataModel); + + return passkey.UseKey(key => + { + Span complementSecret = stackalloc byte[32]; + try + { + VaultParser.DeriveComplementKey(key, _configDataModel.Uid, primaryMethodId, _configDataModel.ComplementGeneration, complementSecret); + return VaultParser.DeriveKeystore(complementSecret, _keystoreDataModel); + } + finally + { + CryptographicOperations.ZeroMemory(complementSecret); + } + }); + } + private (byte[] dekKey, byte[] macKey) DeriveComplementedKeystore(IKeyUsage passkey, AuthenticationMethod authenticationMethod) { ArgumentNullException.ThrowIfNull(_configDataModel); @@ -62,19 +84,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 DeriveFromComplementSecret(passkey, primaryMethodId); } catch (CryptographicException ex) { @@ -95,7 +105,7 @@ public void SetCredentials(IKeyUsage passkey) byte[]? complementSecret = null; try { - complementSecret = passkey.UseKey(key => VaultParser.UnwrapComplementSecret(key, _configDataModel.Uid, share)); + complementSecret = passkey.UseKey(key => VaultParser.UnwrapComplementSecret(key, _configDataModel.Uid, share, _configDataModel.ComplementGeneration)); return VaultParser.DeriveKeystore(complementSecret, _keystoreDataModel); } catch (CryptographicException ex) @@ -109,6 +119,21 @@ 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 passkey.UseKey(key => VaultParser.DeriveKeystore(key, _keystoreDataModel)); + } + catch (CryptographicException ex) + { + lastException = ex; + } + throw lastException ?? new CryptographicException("The complemented credentials could not unlock this vault."); } 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..1b25724a4 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs @@ -30,11 +30,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 @@ -198,13 +197,21 @@ 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,12 +225,13 @@ 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]; @@ -250,7 +258,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,7 +269,7 @@ 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); 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 dba561f37..8119dc53e 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -19,11 +19,15 @@ + + + + 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 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/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.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.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.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 ecdd20576..710f1e99b 100644 --- a/src/Platforms/SecureFolderFS.UI/Constants.cs +++ b/src/Platforms/SecureFolderFS.UI/Constants.cs @@ -16,13 +16,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 d20e56ab8..13915c583 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 @@ + + + @@ -26,6 +30,8 @@ + + diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/AppPlatformAccountProvider.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/AppPlatformAccountProvider.cs new file mode 100644 index 000000000..2d7c9bfd2 --- /dev/null +++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/AppPlatformAccountProvider.cs @@ -0,0 +1,52 @@ +#if APP_PLATFORM_PRESENT +using System.Collections.Generic; +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)) + .ToList(); + } + + /// + public Task RemoveAccountAsync(string accountId, CancellationToken cancellationToken = default) + { + return _deviceKeyStore.ClearAsync(accountId, cancellationToken); + } + } +} +#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/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..39eefca31 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; } @@ -166,7 +166,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 +175,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 +195,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..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/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..688ca4e25 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 }; } diff --git a/src/Platforms/SecureFolderFS.UI/Strings/cs-CZ/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/cs-CZ/Resources.resx index 32d10afa0..ceae27ac5 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/cs-CZ/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/cs-CZ/Resources.resx @@ -1397,7 +1397,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 3715f6788..3f7fe1f31 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/da-DK/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/da-DK/Resources.resx @@ -1397,7 +1397,7 @@ Desktop - + This device diff --git a/src/Platforms/SecureFolderFS.UI/Strings/de-DE/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/de-DE/Resources.resx index 59a91ae0d..9a5cc4f1b 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/de-DE/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/de-DE/Resources.resx @@ -1397,7 +1397,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 ae8617449..2715fad06 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx @@ -854,6 +854,12 @@ Remove + + Accounts + + + Account + Go to vault list @@ -1397,7 +1403,7 @@ Desktop - + This device @@ -1478,4 +1484,466 @@ 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. + + 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|{} devices} + + + 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 Compliance reporting dashboard and export compliance reports. Provides read-only visibility into vault compliance status, recovery key coverage, and access grant health. + + + 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 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 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 + + + Set a recovery key + + + Removed the recovery key + + + {0} recovered vault access + + + Created the group + + + Removed a group with {0:plural:{} member|{} members|{} 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|{} permissions} + + + Removed {0:plural:{} permission|{} permissions|{} permissions} + + + Added {0}, removed {1} permissions + + + {0} signed in for the first time + + + {0} completed App Platform setup + + + 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 + diff --git a/src/Platforms/SecureFolderFS.UI/Strings/es-ES/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/es-ES/Resources.resx index fe26b6c49..ca6e623f8 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/es-ES/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/es-ES/Resources.resx @@ -1397,7 +1397,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 06524d097..b5347847e 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/fr-FR/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/fr-FR/Resources.resx @@ -1397,7 +1397,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 bee80b585..452f54854 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/he-IL/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/he-IL/Resources.resx @@ -1397,7 +1397,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 046082328..eb0385b5a 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/hi-IN/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/hi-IN/Resources.resx @@ -1397,7 +1397,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 bee80b585..452f54854 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/id-ID/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/id-ID/Resources.resx @@ -1397,7 +1397,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 01c54388d..b4dae2534 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/ms-MY/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/ms-MY/Resources.resx @@ -1397,7 +1397,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 e08cc207e..8841dd172 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/pl-PL/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/pl-PL/Resources.resx @@ -1397,7 +1397,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 6d9993a5f..a3f4ba017 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/pt-PT/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/pt-PT/Resources.resx @@ -1397,7 +1397,7 @@ Desktop - + This device diff --git a/src/Platforms/SecureFolderFS.UI/Strings/ro-RO/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/ro-RO/Resources.resx index 4f4f18ed2..ca69cbab2 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/ro-RO/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/ro-RO/Resources.resx @@ -1397,7 +1397,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 4f7431f3f..4e80684ca 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/tr-TR/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/tr-TR/Resources.resx @@ -1397,7 +1397,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 56f930b3e..7e82048e9 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/uk-UA/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/uk-UA/Resources.resx @@ -1398,7 +1398,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 060b3b7b0..a001dd5c3 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/zh-CN/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/zh-CN/Resources.resx @@ -1397,7 +1397,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 69cc7d41a..336e023fe 100644 --- a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs @@ -67,8 +67,6 @@ public partial class App : Application public BaseLifecycleHelper ApplicationLifecycle { get; } = #if WINDOWS new Platforms.Windows.Helpers.WindowsLifecycleHelper(); -#elif MACCATALYST || __MACOS__ - new Platforms.MacCatalyst.Helpers.MacOsLifecycleHelper(); #elif HAS_UNO_SKIA new SkiaLifecycleHelper(); #else 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/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 @@ + + + + + + + + + + + + + + + + +