> AcquireAsync(string id, byte[]? data, C
}
///
- protected override Task ProvideCredentialsAsync(CancellationToken cancellationToken)
+ protected override async Task ProvideCredentialsAsync(CancellationToken cancellationToken)
+ {
+ var vaultReader = new VaultReader(_vaultFolder, StreamSerializer.Instance);
+ var config = await vaultReader.ReadConfigurationAsync(cancellationToken);
+
+ if (config.AppPlatform is null)
+ throw new InvalidOperationException("Vault is not configured for App Platform.");
+
+ var serverUrl = config.AppPlatform.ServerUrl.TrimEnd('/');
+ var vaultId = config.Uid;
+
+ // Find a free localhost port for the callback
+ var tcpListener = new TcpListener(IPAddress.Loopback, 0);
+ tcpListener.Start();
+ var port = ((IPEndPoint)tcpListener.LocalEndpoint).Port;
+ tcpListener.Stop();
+
+ var callbackUri = $"http://localhost:{port}/";
+
+ // The server renders a page that authenticates via Keycloak, decrypts the
+ // vault key in-browser using the user's private key (Web Crypto / IndexedDB),
+ // and redirects the decrypted key to our callback.
+ var unlockUrl = $"{serverUrl}/app/unlock" +
+ $"?vault={Uri.EscapeDataString(vaultId)}" +
+ $"&redirect={Uri.EscapeDataString(callbackUri)}";
+
+ using var httpListener = new HttpListener();
+ httpListener.Prefixes.Add(callbackUri);
+ httpListener.Start();
+
+ try
+ {
+ Process.Start(new ProcessStartInfo(unlockUrl) { UseShellExecute = true });
+
+ var context = await httpListener.GetContextAsync().WaitAsync(cancellationToken);
+
+ // The unlock page POSTs the key in the request body (not the URL)
+ // to avoid leaking decryption keys in browser history / Referer headers.
+ // Error callbacks use GET with ?error= (no sensitive data).
+ byte[] combined;
+ if (context.Request.HttpMethod == "POST")
+ {
+ using var reader = new System.IO.StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
+ var body = await reader.ReadToEndAsync(cancellationToken);
+ var formData = HttpUtility.ParseQueryString(body);
+ var keyParam = formData.Get("key");
+
+ if (string.IsNullOrEmpty(keyParam))
+ {
+ SendHtmlResponse(context, false, "No key in POST body.");
+ throw new InvalidOperationException("App Platform unlock failed: no key received.");
+ }
+
+ SendHtmlResponse(context, true, null);
+ combined = Base64UrlDecode(keyParam);
+ }
+ else
+ {
+ var queryParams = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
+ var errorParam = queryParams.Get("error") ?? "Unknown error";
+ SendHtmlResponse(context, false, errorParam);
+ throw new InvalidOperationException($"App Platform unlock failed: {errorParam}");
+ }
+ try
+ {
+ using var key = ManagedKey.TakeOwnership(combined);
+
+ var tcs = new TaskCompletionSource();
+ CredentialsProvided?.Invoke(this, new(key, tcs));
+ await tcs.Task;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(combined);
+ }
+ }
+ finally
+ {
+ httpListener.Stop();
+ }
+ }
+
+ private static byte[] Base64UrlDecode(string base64Url)
{
- return Task.CompletedTask;
+ var s = base64Url.Replace('-', '+').Replace('_', '/');
+ switch (s.Length % 4)
+ {
+ case 2: s += "=="; break;
+ case 3: s += "="; break;
+ }
+ return Convert.FromBase64String(s);
+ }
+
+ private static void SendHtmlResponse(HttpListenerContext context, bool success, string? errorMessage)
+ {
+ var html = success
+ ? "Vault unlocked
You can close this window.
"
+ : $"Unlock failed
{WebUtility.HtmlEncode(errorMessage)}
";
+
+ var buffer = Encoding.UTF8.GetBytes(html);
+ context.Response.ContentLength64 = buffer.Length;
+ context.Response.ContentType = "text/html; charset=utf-8";
+ context.Response.OutputStream.Write(buffer, 0, buffer.Length);
+ context.Response.OutputStream.Close();
}
}
}
+#endif
From e4254e854c435826a20fed8f3623946c0dce4632 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Mon, 1 Jun 2026 16:55:06 +0200
Subject: [PATCH 4/5] Fixed build
---
.../AppPlatformCreationViewModel.cs | 18 +++++++++++++++---
.../AppPlatformLoginViewModel.cs | 2 --
2 files changed, 15 insertions(+), 5 deletions(-)
diff --git a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformCreationViewModel.cs b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformCreationViewModel.cs
index 0012d1633..40fb9fbba 100644
--- a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformCreationViewModel.cs
+++ b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformCreationViewModel.cs
@@ -1,10 +1,8 @@
-#if APP_PLATFORM_PRESENT
using System;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using SecureFolderFS.Core.Cryptography.Jwe;
-using SecureFolderFS.Sdk.AppPlatform;
using SecureFolderFS.Sdk.Enums;
using SecureFolderFS.Sdk.EventArguments;
using SecureFolderFS.Sdk.ViewModels.Controls.Authentication;
@@ -12,12 +10,17 @@
using SecureFolderFS.Shared.ComponentModel;
using SecureFolderFS.Shared.Models;
using SecureFolderFS.Shared.SecureStore;
+#if APP_PLATFORM_PRESENT
+using SecureFolderFS.Sdk.AppPlatform;
+#endif
namespace SecureFolderFS.UI.ViewModels.Authentication
{
public sealed partial class AppPlatformCreationViewModel : AuthenticationViewModel, IVaultOptionsProvider, IAppPlatformVaultRegistration
{
+#if APP_PLATFORM_PRESENT
private AppPlatformClient? _client;
+#endif
[ObservableProperty] private string? _ServerUrl;
[ObservableProperty] private bool _IsAuthenticated;
@@ -61,6 +64,7 @@ public override Task> AcquireAsync(string id, byte[]? data, C
///
protected override async Task ProvideCredentialsAsync(CancellationToken cancellationToken)
{
+#if APP_PLATFORM_PRESENT
if (string.IsNullOrWhiteSpace(ServerUrl))
throw new InvalidOperationException("A server URL is required.");
@@ -83,6 +87,9 @@ protected override async Task ProvideCredentialsAsync(CancellationToken cancella
var tcs = new TaskCompletionSource();
CredentialsProvided?.Invoke(this, new(ManagedKey.Empty, tcs));
await tcs.Task;
+#else
+ return;
+#endif
}
///
@@ -100,6 +107,7 @@ public VaultOptions AmendVaultOptions(VaultOptions options)
///
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.");
@@ -109,8 +117,10 @@ public async Task RegisterVaultAsync(string vaultId, string? name, IKeyUsage dek
var vaultKeyJwe = GetVaultJweKey(user, dekKey, macKey);
await _client.RegisterVaultAsync(vaultId, name, vaultKeyJwe, description: null, cancellationToken);
+#endif
}
+#if APP_PLATFORM_PRESENT
private static unsafe string GetVaultJweKey(AppPlatformClient.UserInfo userInfo, IKeyUsage dekKey, IKeyUsage macKey)
{
return dekKey.UseKey(dek =>
@@ -126,14 +136,16 @@ private static unsafe string GetVaultJweKey(AppPlatformClient.UserInfo userInfo,
}
});
}
+#endif
///
public override void Dispose()
{
+#if APP_PLATFORM_PRESENT
_client?.Dispose();
_client = null;
+#endif
base.Dispose();
}
}
}
-#endif
diff --git a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformLoginViewModel.cs b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformLoginViewModel.cs
index 469a6426a..4dad47948 100644
--- a/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformLoginViewModel.cs
+++ b/src/Platforms/SecureFolderFS.UI/ViewModels/Authentication/AppPlatformLoginViewModel.cs
@@ -1,4 +1,3 @@
-#if APP_PLATFORM_PRESENT
using System;
using System.Diagnostics;
using System.Net;
@@ -172,4 +171,3 @@ private static void SendHtmlResponse(HttpListenerContext context, bool success,
}
}
}
-#endif
From 56415ffc5f3cdd1baf5071f889a290edac413068 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Mon, 1 Jun 2026 17:01:50 +0200
Subject: [PATCH 5/5] Update Directory.Build.props
---
src/Platforms/Directory.Build.props | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/Platforms/Directory.Build.props b/src/Platforms/Directory.Build.props
index 0ced37112..3436fcc4b 100644
--- a/src/Platforms/Directory.Build.props
+++ b/src/Platforms/Directory.Build.props
@@ -39,9 +39,7 @@
-
- $(DefineConstants);APP_PLATFORM_PRESENT
-
+ $(DefineConstants);APP_PLATFORM_PRESENT