From d23fa8f151c3302dce1de8de8d5cb73354b0c0ed Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:36:04 +0200 Subject: [PATCH 1/9] Consolidate into one project --- SecureFolderFS.Public.slnx | 1 - SecureFolderFS.slnx | 1 - .../SecureFolderFS.Cli/AppApiClient.cs | 362 ++++++++++++++++++ .../SecureFolderFS.Cli.csproj | 1 - .../Enums/ApiTransportKind.cs | 18 - .../SecureFolderFS.Api.csproj | 10 - .../Enums/ApiActionOutcome.cs | 38 -- .../SecureFolderFS.Sdk.Api/Enums/ApiEnums.cs | 106 +++++ .../Enums/ApiRequestClass.cs | 23 -- .../Enums/ApiVaultChangeKind.cs | 33 -- .../SecureFolderFS.Sdk.Api.csproj | 2 +- .../Models/IntegrationClientInfo.cs | 21 + .../Services/ILocalIntegrationsService.cs | 38 ++ .../Overlays/ApiConsentOverlayViewModel.cs | 7 - 14 files changed, 528 insertions(+), 133 deletions(-) create mode 100644 src/Platforms/SecureFolderFS.Cli/AppApiClient.cs delete mode 100644 src/Sdk/SecureFolderFS.Api/Enums/ApiTransportKind.cs delete mode 100644 src/Sdk/SecureFolderFS.Api/SecureFolderFS.Api.csproj delete mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiActionOutcome.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiEnums.cs delete mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiRequestClass.cs delete mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiVaultChangeKind.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk/Models/IntegrationClientInfo.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs diff --git a/SecureFolderFS.Public.slnx b/SecureFolderFS.Public.slnx index d1db8ff8b..84f2aaa1d 100644 --- a/SecureFolderFS.Public.slnx +++ b/SecureFolderFS.Public.slnx @@ -36,7 +36,6 @@ - diff --git a/SecureFolderFS.slnx b/SecureFolderFS.slnx index 4c609c261..b73467008 100644 --- a/SecureFolderFS.slnx +++ b/SecureFolderFS.slnx @@ -93,7 +93,6 @@ - diff --git a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs new file mode 100644 index 000000000..2bf0e9e0a --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs @@ -0,0 +1,362 @@ +using System.IO.Pipes; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace SecureFolderFS.Cli; + +/// +/// Talks to a running SecureFolderFS instance over the local integration API. +/// +/// +/// Written against the BCL alone — no SecureFolderFS assemblies, no shared protocol library. It exists +/// to prove that the published protocol is implementable from the specification by itself: a third party +/// reading docs/local-integration-api.md can reproduce everything here in an afternoon. +/// +internal sealed class AppApiClient : IAsyncDisposable +{ + private const string CLIENT_NAME = "SecureFolderFS CLI"; + private const int PROTOCOL_VERSION = 0; + + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30); + + private readonly Stream _stream; + private readonly StreamReader _reader; + private readonly StreamWriter _writer; + private long _nextId = 1; + + private AppApiClient(Stream stream) + { + _stream = stream; + _reader = new StreamReader(stream, Encoding.UTF8); + // Newline-delimited JSON: one compact object per line, terminated by '\n'. + _writer = new StreamWriter(stream, new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" }; + } + + /// + /// Connects to the running application, pairing if this is the first use. + /// + /// The API was unreachable, or pairing was refused. + public static async Task ConnectAsync(CancellationToken cancellationToken = default) + { + var endpoint = ApiEndpoint.TryRead() + ?? throw new AppApiException("SecureFolderFS does not appear to be installed for this user."); + + if (!endpoint.Enabled) + throw new AppApiException("App integrations are turned off. Enable them in SecureFolderFS under Settings > Preferences."); + + if (PROTOCOL_VERSION < endpoint.ProtocolMin || PROTOCOL_VERSION > endpoint.ProtocolMax) + throw new AppApiException("SecureFolderFS speaks a protocol version this build does not support."); + + var stream = await endpoint.OpenAsync(ConnectTimeout, cancellationToken) + ?? throw new AppApiException("SecureFolderFS is not running."); + + var client = new AppApiClient(stream); + try + { + await client.HandshakeAsync(cancellationToken); + return client; + } + catch + { + await client.DisposeAsync(); + throw; + } + } + + private async Task HandshakeAsync(CancellationToken cancellationToken) + { + var token = TokenStore.TryRead(); + + var hello = await CallAsync("hello", new + { + protocolMin = PROTOCOL_VERSION, + protocolMax = PROTOCOL_VERSION, + clientName = CLIENT_NAME, + token + }, cancellationToken); + + if (hello.GetProperty("state").GetString() == "paired") + return; + + // A stored token that no longer authenticates means the user revoked us. + if (token is not null) + TokenStore.Clear(); + + // 'pair' raises a consent dialog in SecureFolderFS; only ever call it in response to a user action. + var pair = await CallAsync("pair", new { scopes = new[] { "vaults.read", "vaults.trigger" } }, cancellationToken); + TokenStore.Write(pair.GetProperty("token").GetString()!); + } + + /// + /// Lists every vault visible to integrations. + /// + public async Task> ListVaultsAsync(CancellationToken cancellationToken = default) + { + var result = await CallAsync("vaults.list", null, cancellationToken); + + var vaults = new List(); + foreach (var vault in result.GetProperty("vaults").EnumerateArray()) + { + vaults.Add(new ApiVault( + vault.GetProperty("id").GetString() ?? string.Empty, + vault.GetProperty("name").GetString() ?? string.Empty, + vault.GetProperty("state").GetString() ?? string.Empty, + vault.TryGetProperty("mountPath", out var mountPath) ? mountPath.GetString() : null)); + } + + return vaults; + } + + /// + /// Asks the application to show its unlock prompt for a vault. Returns the reported status. + /// + public Task RequestUnlockAsync(string vaultId, CancellationToken cancellationToken = default) + => CallForStatusAsync("vaults.requestUnlock", vaultId, cancellationToken); + + /// + /// Locks an unlocked vault. Returns the reported status. + /// + public Task LockAsync(string vaultId, CancellationToken cancellationToken = default) + => CallForStatusAsync("vaults.lock", vaultId, cancellationToken); + + private async Task CallForStatusAsync(string method, string vaultId, CancellationToken cancellationToken) + { + var result = await CallAsync(method, new { vaultId }, cancellationToken); + return result.TryGetProperty("status", out var status) ? status.GetString() ?? "ok" : "ok"; + } + + private async Task CallAsync(string method, object? parameters, CancellationToken cancellationToken) + { + var id = _nextId++; + await _writer.WriteLineAsync(JsonSerializer.Serialize(new { id, method, @params = parameters })); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(RequestTimeout); + + while (true) + { + var line = await _reader.ReadLineAsync(timeout.Token) + ?? throw new AppApiException($"SecureFolderFS closed the connection during '{method}'."); + + if (line.Length == 0) + continue; + + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + + // Notifications carry no id, and replies to other requests may interleave with this one. + if (!root.TryGetProperty("id", out var replyId) || replyId.ValueKind == JsonValueKind.Null || replyId.GetInt64() != id) + continue; + + if (root.TryGetProperty("error", out var error)) + throw new AppApiException(DescribeError(method, error)); + + return root.TryGetProperty("result", out var result) ? result.Clone() : default; + } + } + + private static string DescribeError(string method, JsonElement error) + { + var code = error.TryGetProperty("code", out var c) ? c.GetString() : null; + var message = error.TryGetProperty("message", out var m) ? m.GetString() : null; + var retryAfterMs = error.TryGetProperty("retryAfterMs", out var r) && r.ValueKind == JsonValueKind.Number ? r.GetInt32() : 0; + + return code switch + { + "pairing_denied" => "SecureFolderFS declined the connection.", + "pairing_unavailable" => "SecureFolderFS is not accepting a pairing request right now. Try again shortly.", + "forbidden_scope" => "This tool was not granted permission for that action.", + "rate_limited" => $"Rate limited; retry in {retryAfterMs / 1000}s.", + "not_found" => "No such vault.", + _ => $"'{method}' failed ({code}): {message}" + }; + } + + /// + public async ValueTask DisposeAsync() + { + _reader.Dispose(); + await _writer.DisposeAsync(); + await _stream.DisposeAsync(); + } + + /// + /// A vault as reported by the API. + /// + internal sealed record ApiVault(string Id, string Name, string State, string? MountPath); + + /// + /// Locates and connects to the endpoint by reading the persistent endpoint file, exactly as the + /// protocol specification describes. No secrets live here — the file is only a map. + /// + private sealed record ApiEndpoint(string Transport, string Address, bool Enabled, int ProtocolMin, int ProtocolMax) + { + public static ApiEndpoint? TryRead() + { + try + { + var path = Path.Combine(GetPersistentDirectory(), "api-endpoint.json"); + if (!File.Exists(path)) + return null; + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var root = document.RootElement; + + return new ApiEndpoint( + root.GetProperty("transport").GetString() ?? string.Empty, + root.GetProperty("address").GetString() ?? string.Empty, + root.TryGetProperty("enabled", out var enabled) && enabled.GetBoolean(), + root.TryGetProperty("protocolMin", out var min) ? min.GetInt32() : 0, + root.TryGetProperty("protocolMax", out var max) ? max.GetInt32() : 0); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Opens the transport, or when nothing is listening. Liveness is only ever + /// determined by attempting the connection, never inferred from the file's presence. + /// + public async Task OpenAsync(TimeSpan connectTimeout, CancellationToken cancellationToken) + { + try + { + return Transport switch + { + "namedPipe" => await ConnectPipeAsync(Address, connectTimeout, cancellationToken), + "unixSocket" => await ConnectSocketAsync(Address, cancellationToken), + _ => null + }; + } + catch (Exception ex) when (ex is SocketException or IOException or TimeoutException or UnauthorizedAccessException) + { + return null; + } + } + + private static async Task ConnectPipeAsync(string address, TimeSpan connectTimeout, CancellationToken cancellationToken) + { + // On Windows the advertised address is the bare pipe name; tolerate a \\.\pipe\ prefix too. + var pipeName = address.StartsWith(@"\\.\pipe\", StringComparison.OrdinalIgnoreCase) + ? address[@"\\.\pipe\".Length..] + : address; + + var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(connectTimeout); + + await pipe.ConnectAsync(timeout.Token); + return pipe; + } + catch + { + await pipe.DisposeAsync(); + throw; + } + } + + private static async Task ConnectSocketAsync(string address, CancellationToken cancellationToken) + { + if (!File.Exists(address)) + return null; + + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(address), cancellationToken); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + + private static string GetPersistentDirectory() + { + if (OperatingSystem.IsWindows()) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "SecureFolderFS"); + } + + if (OperatingSystem.IsMacOS()) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", "Application Support", "SecureFolderFS"); + } + + var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (string.IsNullOrEmpty(configHome)) + configHome = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); + + return Path.Combine(configHome, "securefolderfs"); + } + } + + /// + /// Persists the pairing token for this tool. + /// + private static class TokenStore + { + private static string FilePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".securefolderfs", + "cli-api-token"); + + public static string? TryRead() + { + try + { + return File.Exists(FilePath) ? File.ReadAllText(FilePath).Trim() : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + public static void Write(string token) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + File.WriteAllText(FilePath, token); + + // The token authorizes this tool, so keep it readable only by its owner. + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(FilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Losing the token only means pairing again next time. + } + } + + public static void Clear() + { + try + { + File.Delete(FilePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort. + } + } + } +} + +/// +/// Represents a failure talking to the local integration API. +/// +internal sealed class AppApiException(string message) : Exception(message); diff --git a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj index 7b079eb36..59f753706 100644 --- a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj +++ b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj @@ -15,7 +15,6 @@ - diff --git a/src/Sdk/SecureFolderFS.Api/Enums/ApiTransportKind.cs b/src/Sdk/SecureFolderFS.Api/Enums/ApiTransportKind.cs deleted file mode 100644 index 6642fa071..000000000 --- a/src/Sdk/SecureFolderFS.Api/Enums/ApiTransportKind.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace SecureFolderFS.Api.Enums -{ - /// - /// Identifies the local IPC mechanism carrying a connection. - /// - public enum ApiTransportKind - { - /// - /// A Windows named pipe, restricted by an explicit DACL. - /// - NamedPipe, - - /// - /// A Unix domain socket, restricted by filesystem permissions. - /// - UnixSocket - } -} diff --git a/src/Sdk/SecureFolderFS.Api/SecureFolderFS.Api.csproj b/src/Sdk/SecureFolderFS.Api/SecureFolderFS.Api.csproj deleted file mode 100644 index c9ea32713..000000000 --- a/src/Sdk/SecureFolderFS.Api/SecureFolderFS.Api.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - - net10.0 - latest - enable - disable - - - diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiActionOutcome.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiActionOutcome.cs deleted file mode 100644 index f408e20d6..000000000 --- a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiActionOutcome.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace SecureFolderFS.Sdk.Api.Enums -{ - /// - /// Describes the outcome of an action requested through the API. - /// - public enum ApiActionOutcome - { - /// - /// The action was performed, or the requested user interface was shown. - /// - Ok, - - /// - /// A prompt for this vault was already open, so no additional one was raised. - /// - AlreadyPending, - - /// - /// The vault was already in the requested state. - /// - NoChange, - - /// - /// No vault with the supplied identifier is visible to integrations. - /// - NotFound, - - /// - /// The action does not apply while the vault is in its current state. - /// - InvalidState, - - /// - /// The application is not ready to service the request. - /// - Unavailable - } -} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiEnums.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiEnums.cs new file mode 100644 index 000000000..adddae560 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiEnums.cs @@ -0,0 +1,106 @@ +namespace SecureFolderFS.Sdk.Api.Enums +{ + /// + /// Identifies the local IPC mechanism carrying a connection. + /// + public enum ApiTransportKind + { + /// + /// A Windows named pipe, restricted by an explicit DACL. + /// + NamedPipe, + + /// + /// A Unix domain socket, restricted by filesystem permissions. + /// + UnixSocket + } + + /// + /// Describes the outcome of an action requested through the API. + /// + public enum ApiActionOutcome + { + /// + /// The action was performed, or the requested user interface was shown. + /// + Ok, + + /// + /// A prompt for this vault was already open, so no additional one was raised. + /// + AlreadyPending, + + /// + /// The vault was already in the requested state. + /// + NoChange, + + /// + /// No vault with the supplied identifier is visible to integrations. + /// + NotFound, + + /// + /// The action does not apply while the vault is in its current state. + /// + InvalidState, + + /// + /// The application is not ready to service the request. + /// + Unavailable + } + + /// + /// Classifies a request by its rate-limit budget. + /// + public enum ApiRequestType + { + /// + /// Reads application state. + /// + Read, + + /// + /// Raises a window or changes vault state. + /// + Trigger, + + /// + /// Asks for consent. + /// + Pairing + } + + /// + /// Describes the kind of change that occurred to a vault. + /// + public enum ApiVaultChangeKind + { + /// + /// A vault became visible to integrations. + /// + Added, + + /// + /// A vault stopped being visible to integrations, whether removed or hidden. + /// + Removed, + + /// + /// A vault's display name changed. + /// + Renamed, + + /// + /// A vault was mounted. + /// + Unlocked, + + /// + /// A vault was unmounted. + /// + Locked + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiRequestClass.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiRequestClass.cs deleted file mode 100644 index dfbb995a6..000000000 --- a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiRequestClass.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace SecureFolderFS.Sdk.Api.Enums -{ - /// - /// Classifies a request by its expensiveness. - /// - public enum ApiRequestClass - { - /// - /// Reads application state. - /// - Read, - - /// - /// Raises a window or changes vault state. - /// - Trigger, - - /// - /// Asks for consent. - /// - Pairing - } -} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiVaultChangeKind.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiVaultChangeKind.cs deleted file mode 100644 index caf5ce0a4..000000000 --- a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiVaultChangeKind.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace SecureFolderFS.Sdk.Api.Enums -{ - /// - /// Describes the kind of change that occurred to a vault. - /// - public enum ApiVaultChangeKind - { - /// - /// A vault became visible to integrations. - /// - Added, - - /// - /// A vault stopped being visible to integrations, whether removed or hidden. - /// - Removed, - - /// - /// A vault's display name changed. - /// - Renamed, - - /// - /// A vault was mounted. - /// - Unlocked, - - /// - /// A vault was unmounted. - /// - Locked - } -} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj b/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj index cad01a01d..fe26f5b96 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj +++ b/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/Sdk/SecureFolderFS.Sdk/Models/IntegrationClientInfo.cs b/src/Sdk/SecureFolderFS.Sdk/Models/IntegrationClientInfo.cs new file mode 100644 index 000000000..7e383c476 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/Models/IntegrationClientInfo.cs @@ -0,0 +1,21 @@ +using System; + +namespace SecureFolderFS.Sdk.Models +{ + /// + /// Describes an application the user has authorized to use the local integration API. + /// + /// The identifier of the pairing, used to revoke it. + /// The name shown to the user. + /// Whether the platform verified the publisher when it was authorized. + /// The verified publisher, when one was established. + /// Where the application lived when it was authorized. + /// When it last connected, or if it never has. + public sealed record IntegrationClientInfo( + string Id, + string DisplayName, + bool IsIdentityVerified, + string? Signer, + string? ExecutablePath, + DateTimeOffset? LastUsedAt); +} diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs new file mode 100644 index 000000000..7f3d41074 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Models; + +namespace SecureFolderFS.Sdk.Services +{ + /// + /// Manages the local integration API on behalf of the settings interface. + /// + public interface ILocalIntegrationsService + { + /// + /// Starts or stops the API endpoint and records the choice. + /// + /// Whether other applications may connect. + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task SetEnabledAsync(bool isEnabled, CancellationToken cancellationToken = default); + + /// + /// Gets every application currently authorized to connect. + /// + IReadOnlyList GetClients(); + + /// + /// Withdraws an application's authorization. It must ask the user again to reconnect. + /// + /// The identifier from . + void RevokeClient(string clientId); + + /// + /// Withdraws every application's authorization and forgets every recorded refusal, so a mistaken + /// denial does not leave the application waiting out the cooldown. + /// + void RevokeAllClients(); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/ApiConsentOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/ApiConsentOverlayViewModel.cs index 971a1a86d..4c9f97793 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/ApiConsentOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/ApiConsentOverlayViewModel.cs @@ -37,11 +37,6 @@ public sealed partial class ApiConsentOverlayViewModel : OverlayViewModel /// [ObservableProperty] private string _IdentityDescription; - /// - /// Gets the scopes being requested, as identifiers. - /// - public ObservableCollection RequestedScopes { get; } - /// /// Gets the human-readable description of what the caller is asking to do. /// @@ -52,14 +47,12 @@ public ApiConsentOverlayViewModel( string? executablePath, string? signer, bool isIdentityVerified, - IEnumerable requestedScopes, IEnumerable requestedPermissions) { ClientName = clientName; ExecutablePath = executablePath; Signer = signer; IsIdentityVerified = isIdentityVerified; - RequestedScopes = new(requestedScopes); RequestedPermissions = new(requestedPermissions); Title = "ApiConsentTitle".ToLocalized(); From cbe972e4bcced90b3c3cf72ae8a8924afeedd9b7 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:43:22 +0200 Subject: [PATCH 2/9] Added AppVaultsCommand --- .../SecureFolderFS.Cli/AppApiClient.cs | 15 +-- .../SecureFolderFS.Cli/CliCommandHelpers.cs | 4 + .../Commands/AppVaultsCommand.cs | 103 ++++++++++++++++++ .../Services/IApiHost.cs | 34 ++++++ 4 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiHost.cs diff --git a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs index 2bf0e9e0a..eccdffc41 100644 --- a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs +++ b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs @@ -8,11 +8,6 @@ namespace SecureFolderFS.Cli; /// /// Talks to a running SecureFolderFS instance over the local integration API. /// -/// -/// Written against the BCL alone — no SecureFolderFS assemblies, no shared protocol library. It exists -/// to prove that the published protocol is implementable from the specification by itself: a third party -/// reading docs/local-integration-api.md can reproduce everything here in an afternoon. -/// internal sealed class AppApiClient : IAsyncDisposable { private const string CLIENT_NAME = "SecureFolderFS CLI"; @@ -30,7 +25,7 @@ private AppApiClient(Stream stream) { _stream = stream; _reader = new StreamReader(stream, Encoding.UTF8); - // Newline-delimited JSON: one compact object per line, terminated by '\n'. + // Newline-delimited JSON _writer = new StreamWriter(stream, new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" }; } @@ -68,7 +63,6 @@ public static async Task ConnectAsync(CancellationToken cancellati private async Task HandshakeAsync(CancellationToken cancellationToken) { var token = TokenStore.TryRead(); - var hello = await CallAsync("hello", new { protocolMin = PROTOCOL_VERSION, @@ -84,7 +78,7 @@ private async Task HandshakeAsync(CancellationToken cancellationToken) if (token is not null) TokenStore.Clear(); - // 'pair' raises a consent dialog in SecureFolderFS; only ever call it in response to a user action. + // 'pair' raises a consent dialog var pair = await CallAsync("pair", new { scopes = new[] { "vaults.read", "vaults.trigger" } }, cancellationToken); TokenStore.Write(pair.GetProperty("token").GetString()!); } @@ -189,7 +183,7 @@ internal sealed record ApiVault(string Id, string Name, string State, string? Mo /// /// Locates and connects to the endpoint by reading the persistent endpoint file, exactly as the - /// protocol specification describes. No secrets live here — the file is only a map. + /// protocol specification describes. /// private sealed record ApiEndpoint(string Transport, string Address, bool Enabled, int ProtocolMin, int ProtocolMax) { @@ -218,8 +212,7 @@ private sealed record ApiEndpoint(string Transport, string Address, bool Enabled } /// - /// Opens the transport, or when nothing is listening. Liveness is only ever - /// determined by attempting the connection, never inferred from the file's presence. + /// Opens the transport, or when nothing is listening. /// public async Task OpenAsync(TimeSpan connectTimeout, CancellationToken cancellationToken) { diff --git a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs index 34aab586f..c8c4fb827 100644 --- a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs +++ b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs @@ -51,6 +51,10 @@ public static int HandleException(Exception ex, IConsole console, CliGlobalOptio { switch (ex) { + case AppApiException: + CliOutput.Error(console, options, ex.Message); + return CliExitCodes.GeneralError; + case CryptographicException: case FormatException: CliOutput.Error(console, options, ex.Message); diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs new file mode 100644 index 000000000..f13363eec --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs @@ -0,0 +1,103 @@ +using CliFx; +using CliFx.Binding; +using CliFx.Infrastructure; + +namespace SecureFolderFS.Cli.Commands; + +/// +/// Lists the vaults known to a running SecureFolderFS instance. +/// +[Command("app vaults", Description = "List vaults from a running SecureFolderFS instance.")] +public sealed partial class AppVaultsCommand : CliGlobalOptions, ICommand +{ + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + await using var client = await AppApiClient.ConnectAsync(console.RegisterCancellationHandler()); + var vaults = await client.ListVaultsAsync(); + + if (vaults.Count == 0) + { + CliOutput.Info(console, this, "No vaults are visible to integrations."); + Environment.ExitCode = CliExitCodes.Success; + return; + } + + foreach (var vault in vaults) + { + var suffix = vault.State == "unlocked" ? $" {vault.MountPath}" : string.Empty; + console.Output.WriteLine($"{vault.Id} {vault.State,-8} {vault.Name}{suffix}"); + } + + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } +} + +/// +/// Asks a running SecureFolderFS instance to show its unlock prompt. +/// +[Command("app unlock", Description = "Ask a running SecureFolderFS instance to prompt for unlocking a vault.")] +public sealed partial class AppUnlockCommand : CliGlobalOptions, ICommand +{ + [CommandParameter(0, Name = "vaultId", Description = "Vault id as reported by 'app vaults'.")] + public required string VaultId { get; set; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + await using var client = await AppApiClient.ConnectAsync(console.RegisterCancellationHandler()); + var status = await client.RequestUnlockAsync(VaultId); + + // The prompt is shown to the user; this command deliberately does not wait for the outcome, + // because the user may take any amount of time or cancel outright. + CliOutput.Info(console, this, status switch + { + "already_pending" => "An unlock prompt for this vault is already open.", + "no_change" => "The vault is already unlocked.", + _ => "SecureFolderFS is prompting for credentials." + }); + + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } +} + +/// +/// Locks a vault in a running SecureFolderFS instance. +/// +[Command("app lock", Description = "Lock a vault in a running SecureFolderFS instance.")] +public sealed partial class AppLockCommand : CliGlobalOptions, ICommand +{ + [CommandParameter(0, Name = "vaultId", Description = "Vault id as reported by 'app vaults'.")] + public required string VaultId { get; set; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + try + { + await using var client = await AppApiClient.ConnectAsync(console.RegisterCancellationHandler()); + var status = await client.LockAsync(VaultId); + + CliOutput.Info(console, this, status == "no_change" + ? "The vault is already locked." + : "Lock requested."); + + Environment.ExitCode = CliExitCodes.Success; + } + catch (Exception ex) + { + Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiHost.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiHost.cs new file mode 100644 index 000000000..006a0d3b0 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiHost.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace SecureFolderFS.Sdk.Api.Services +{ + /// + /// Hosts the local integration API endpoint. + /// + public interface IApiHost : IAsyncDisposable + { + /// + /// Binds the endpoint and begins accepting clients. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task StartAsync(CancellationToken cancellationToken = default); + + /// + /// Stops accepting clients and disconnects existing ones. + /// + /// A that cancels this action. + /// + /// The endpoint file is left advertising the API as enabled, so a client can tell "turned off" from "not running". + /// + /// A that represents the asynchronous operation. + Task StopAsync(CancellationToken cancellationToken = default); + + /// + /// Records in the endpoint file that the user has disabled local integrations. + /// + void PublishDisabled(); + } +} From cee554588916262a0a81c7fe76c8aa77db44d671 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:25:46 +0200 Subject: [PATCH 3/9] Added IApiConsentService --- .../SecureFolderFS.Cli/AppApiClient.cs | 2 +- .../Helpers/SwipeSelectionManager.cs | 4 +- .../UserControls/Common/GalleryView.cs | 2 +- .../UnoApiConsentService.cs | 61 ++++++++++ src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs | 95 ++++++++++++++++ .../Models/PairingModels.cs | 38 +++++++ .../Protocol/ApiMessage.cs | 93 +++++++++++++++ .../Protocol/ApiModels.cs | 107 ++++++++++++++++++ .../Services/IApiConsentService.cs | 20 ++++ .../Services/ILocalIntegrationsService.cs | 8 +- 10 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoApiConsentService.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiConsentService.cs diff --git a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs index eccdffc41..81e0a7a19 100644 --- a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs +++ b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs @@ -11,7 +11,7 @@ namespace SecureFolderFS.Cli; internal sealed class AppApiClient : IAsyncDisposable { private const string CLIENT_NAME = "SecureFolderFS CLI"; - private const int PROTOCOL_VERSION = 0; + private const int PROTOCOL_VERSION = 1; private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30); diff --git a/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs b/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs index 8352e62dc..2fee405dc 100644 --- a/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs +++ b/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs @@ -40,13 +40,13 @@ public void UpdateFromRectangle(IList allItems, Func + internal sealed class UnoApiConsentService : IApiConsentService + { + /// + public async Task RequestConsentAsync( + ApiConsentRequest request, + CancellationToken cancellationToken = default) + { + var overlayService = DI.OptionalService(); + if (overlayService is null) // Unlikely + return ApiConsentResult.Denied; + + var viewModel = new ApiConsentOverlayViewModel( + request.ClientName, + request.Evidence.ExecutablePath, + request.Evidence.Signer, + request.Evidence.IsVerified, + DescribeScopes(request.RequestedScopes)); + + var isGranted = false; + await App.Instance?.MainWindowSynchronizationContext.PostOrExecuteAsync(async () => + { + var result = await overlayService.ShowAsync(viewModel); + isGranted = result.Positive(); + })!; + + return isGranted + ? new ApiConsentResult(true, request.RequestedScopes) + : ApiConsentResult.Denied; + } + + private static IReadOnlyList DescribeScopes(IReadOnlyList scopes) + { + return scopes.Select(scope => scope switch + { + ApiConstants.Scopes.VAULTS_READ => "ApiConsentScopeVaultsRead", + ApiConstants.Scopes.VAULTS_TRIGGER => "ApiConsentScopeVaultsTrigger", + ApiConstants.Scopes.APP_CONTROL => "ApiConsentScopeAppControl", + _ => null + }) + .OfType() + .Select(key => $"• {key.ToLocalized()}") + .ToArray(); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs new file mode 100644 index 000000000..2d23a6897 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; + +namespace SecureFolderFS.Sdk.Api +{ + public static class Constants + { + public const int PROTOCOL_VERSION = 1; + public const int PROTOCOL_VERSION_MIN = 1; + public const string ENDPOINT_FILE_NAME = "api-endpoint.json"; + public const string APP_DIRECTORY_NAME = "SecureFolderFS"; + public const string XDG_DIRECTORY_NAME = "securefolderfs"; + + public static class Methods + { + public const string HELLO = "hello"; + public const string PAIR = "pair"; + public const string VAULTS_LIST = "vaults.list"; + public const string VAULTS_SUBSCRIBE = "vaults.subscribe"; + public const string VAULTS_UNSUBSCRIBE = "vaults.unsubscribe"; + public const string VAULTS_REQUEST_UNLOCK = "vaults.requestUnlock"; + public const string VAULTS_LOCK = "vaults.lock"; + public const string VAULTS_REVEAL = "vaults.reveal"; + public const string APP_SHOW = "app.show"; + } + + public static class Events + { + public const string VAULT_ADDED = "vault.added"; + public const string VAULT_REMOVED = "vault.removed"; + public const string VAULT_RENAMED = "vault.renamed"; + public const string VAULT_UNLOCKED = "vault.unlocked"; + public const string VAULT_LOCKED = "vault.locked"; + } + + public static class Scopes + { + /// Allows listing vaults and subscribing to changes. + public const string VAULTS_READ = "vaults.read"; + + /// Allows raising the unlock prompt, locking, and revealing mounted vaults. + public const string VAULTS_TRIGGER = "vaults.trigger"; + + /// Allows bringing the main window to the foreground. + public const string APP_CONTROL = "app.control"; + + public static IReadOnlyList All { get; } = [VAULTS_READ, VAULTS_TRIGGER, APP_CONTROL]; + } + + public static class ErrorCodes + { + public const string PARSE_ERROR = "parse_error"; + public const string INVALID_REQUEST = "invalid_request"; + public const string UNKNOWN_METHOD = "unknown_method"; + public const string INVALID_PARAMS = "invalid_params"; + public const string UNAUTHORIZED = "unauthorized"; + public const string FORBIDDEN_SCOPE = "forbidden_scope"; + public const string RATE_LIMITED = "rate_limited"; + public const string PAIRING_REQUIRED = "pairing_required"; + public const string PAIRING_DENIED = "pairing_denied"; + public const string PAIRING_UNAVAILABLE = "pairing_unavailable"; + public const string NOT_FOUND = "not_found"; + public const string INVALID_STATE = "invalid_state"; + public const string UNSUPPORTED_VERSION = "unsupported_version"; + public const string INTERNAL_ERROR = "internal_error"; + } + + public static class VaultStates + { + public const string LOCKED = "locked"; + public const string UNLOCKED = "unlocked"; + } + + public static class ActionStatus + { + public const string OK = "ok"; + public const string ALREADY_PENDING = "already_pending"; + public const string NO_CHANGE = "no_change"; + } + + public static class SessionStates + { + public const string PAIRED = "paired"; + public const string PAIRING_REQUIRED = "pairing_required"; + } + + public static class Limits + { + public const int MAX_MESSAGE_BYTES = 64 * 1024; + public const int MAX_CONNECTIONS = 16; + public const int MAX_UNAUTHENTICATED_CONNECTIONS = 4; + public const int HANDSHAKE_TIMEOUT_SECONDS = 30; + public const int MAX_CLIENT_NAME_LENGTH = 64; + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs new file mode 100644 index 000000000..f0c6e4de6 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace SecureFolderFS.Sdk.Api.Models +{ + /// + /// An application the user has authorized to use the local API. + /// + /// A stable internal identifier for this pairing. + /// The name shown in the paired-clients list. + /// The Base64 SHA-256 digest of the issued token. + /// Whether the platform verified the publisher at pairing time. + /// Where the client executable lived at pairing time. + /// The verified publisher, when one was established. + /// The permissions the user granted. + /// When the pairing was established. + /// When the pairing was last used to authenticate. + public sealed record PairedClient( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("displayName")] string DisplayName, + [property: JsonPropertyName("tokenHash")] string TokenHash, + [property: JsonPropertyName("wasIdentityVerified")] bool WasIdentityVerified, + [property: JsonPropertyName("executablePath")] string? ExecutablePath, + [property: JsonPropertyName("signer")] string? Signer, + [property: JsonPropertyName("scopes")] IReadOnlyList Scopes, + [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt, + [property: JsonPropertyName("lastUsedAt")] DateTimeOffset? LastUsedAt); + + /// + /// Records that the user refused an application, so it cannot immediately ask again. + /// + /// A digest identifying the refused caller. + /// When the refusal happened. + public sealed record DeniedClient( + [property: JsonPropertyName("fingerprint")] string Fingerprint, + [property: JsonPropertyName("deniedAt")] DateTimeOffset DeniedAt); +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs new file mode 100644 index 000000000..36ddc43b7 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SecureFolderFS.Sdk.Api.Serialization; + +namespace SecureFolderFS.Sdk.Api.Protocol +{ + /// + /// A single API message that describes a request, response, or notification. + /// + public sealed record ApiMessage + { + /// + /// Gets the correlation identifier, or for a notification. + /// + [JsonPropertyName("id")] + public long? Id { get; init; } + + /// + /// Gets the invoked method or the emitted event name. + /// + [JsonPropertyName("method")] + public string? Method { get; init; } + + /// + /// Gets the request or notification payload. + /// + [JsonPropertyName("params")] + public object? Params { get; init; } + + /// + /// Gets the successful response payload. + /// + [JsonPropertyName("result")] + public object? Result { get; init; } + + /// + /// Gets the failure details, when the request did not succeed. + /// + [JsonPropertyName("error")] + public ApiError? Error { get; init; } + + /// + /// Creates a request message. + /// + public static ApiMessage Request(long id, string method, object? parameters = null) + => new() { Id = id, Method = method, Params = parameters }; + + /// + /// Creates a successful response. + /// + public static ApiMessage Response(long id, object? result) + => new() { Id = id, Result = result }; + + /// + /// Creates a failure response. + /// + public static ApiMessage Failure(long? id, string code, string message, int? retryAfterMs = null) + => new() { Id = id, Error = new ApiError(code, message, retryAfterMs) }; + + /// + /// Creates a server-to-client notification, which carries no identifier. + /// + public static ApiMessage Notification(string method, object? parameters) + => new() { Method = method, Params = parameters }; + + /// + /// Deserializes into . + /// + /// The type to deserialize into. + /// The deserialized object, or when absent or not decodable. + public T? GetParams() where T : class + { + if (Params is not JsonElement element) + return Params as T; + + if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + return null; + + return element.Deserialize(ApiSerializer.Options); + } + } + + /// + /// A structured failure returned in place of a result. + /// + /// A stable machine-readable code from . + /// A human-readable description. + /// When rate limited, how long to wait before retrying. + public sealed record ApiError( + [property: JsonPropertyName("code")] string Code, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("retryAfterMs")] int? RetryAfterMs = null); +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs new file mode 100644 index 000000000..67e9fd808 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace SecureFolderFS.Sdk.Api.Protocol +{ + /// + /// The first message a client sends after connecting. + /// + /// The lowest protocol version the client can speak. + /// The highest protocol version the client can speak. + /// A human-readable name shown in the consent prompt. Untrusted. + /// A token from a previous successful pairing, if the client has one. + public sealed record HelloRequest( + [property: JsonPropertyName("protocolMin")] int ProtocolMin, + [property: JsonPropertyName("protocolMax")] int ProtocolMax, + [property: JsonPropertyName("clientName")] string? ClientName, + [property: JsonPropertyName("token")] string? Token = null); + + /// + /// The server's answer to . + /// + /// The protocol version that will be used for this session. + /// The SecureFolderFS application version, for diagnostics. + /// One of . + /// The optional features this server supports. + /// The scopes granted to this session, empty when not yet paired. + public sealed record HelloResponse( + [property: JsonPropertyName("protocolVersion")] int ProtocolVersion, + [property: JsonPropertyName("serverVersion")] string ServerVersion, + [property: JsonPropertyName("state")] string State, + [property: JsonPropertyName("capabilities")] IReadOnlyList Capabilities, + [property: JsonPropertyName("scopes")] IReadOnlyList Scopes); + + /// + /// A request to raise the consent prompt and obtain a token. + /// + public sealed record PairRequest + { + /// + /// Gets the scopes being requested. When omitted, the default scopes are requested. + /// + [JsonPropertyName("scopes")] + public IReadOnlyList? Scopes { get; init; } + } + + /// + /// A successful pairing. + /// + /// The bearer token to persist and present in future handshakes. + /// The scopes the user actually granted. + public sealed record PairResponse( + [property: JsonPropertyName("token")] string Token, + [property: JsonPropertyName("scopes")] IReadOnlyList Scopes); + + /// + /// A vault as seen by an integration. + /// + /// The public, stable identifier. + /// The vault's display name. + /// One of . + /// The plaintext root, present only while unlocked. + /// When the vault was last opened, if known. + public sealed record VaultInfo( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("state")] string State, + [property: JsonPropertyName("mountPath")] string? MountPath = null, + [property: JsonPropertyName("lastAccess")] DateTimeOffset? LastAccess = null); + + /// + /// A snapshot of every vault visible to integrations. + /// + /// A monotonic revision. A client that observes a gap missed an event and should re-request a snapshot. + /// The vaults, in the order the user arranged them. + public sealed record VaultListResponse( + [property: JsonPropertyName("revision")] long Revision, + [property: JsonPropertyName("vaults")] IReadOnlyList Vaults); + + /// + /// A request that targets a single vault. + /// + /// The public identifier from . + public sealed record VaultRequest( + [property: JsonPropertyName("vaultId")] string? VaultId); + + /// + /// A notification carrying a vault's current state. + /// + public sealed record VaultChangedNotification( + [property: JsonPropertyName("revision")] long Revision, + [property: JsonPropertyName("vault")] VaultInfo Vault); + + /// + /// A notification that a vault is no longer visible to integrations. + /// + public sealed record VaultRemovedNotification( + [property: JsonPropertyName("revision")] long Revision, + [property: JsonPropertyName("vaultId")] string VaultId); + + /// + /// The outcome of a trigger method. + /// + /// One of . + public sealed record ActionResponse( + [property: JsonPropertyName("status")] string Status); +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiConsentService.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiConsentService.cs new file mode 100644 index 000000000..007d3cda8 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiConsentService.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Api.Models; + +namespace SecureFolderFS.Sdk.Api.Services +{ + /// + /// Asks the user whether an application may connect. + /// + public interface IApiConsentService + { + /// + /// Shows a consent prompt and waits for the user's decision. + /// + /// The consent request. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the user's answer.' + Task RequestConsentAsync(ApiConsentRequest request, CancellationToken cancellationToken = default); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs index 7f3d41074..d027e634e 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs @@ -27,12 +27,16 @@ public interface ILocalIntegrationsService /// Withdraws an application's authorization. It must ask the user again to reconnect. /// /// The identifier from . - void RevokeClient(string clientId); + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task RevokeClientAsync(string clientId, CancellationToken cancellationToken = default); /// /// Withdraws every application's authorization and forgets every recorded refusal, so a mistaken /// denial does not leave the application waiting out the cooldown. /// - void RevokeAllClients(); + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task RevokeAllClientsAsync(CancellationToken cancellationToken = default); } } From 2868c1945e4c0d5c52ce0a0ad0042590dbbfc14f Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:09:52 +0200 Subject: [PATCH 4/9] Connect services --- src/Platforms/SecureFolderFS.UI/Constants.cs | 1 + src/Platforms/SecureFolderFS.Uno/App.xaml.cs | 162 ++++++++++++------ .../Extensions/UnoIocExtensions.cs | 20 +++ .../UnoLocalIntegrationsService.cs | 67 ++++++++ .../ApiVaultChangedEventArgs.cs | 27 +++ .../Models/ConsentModels.cs | 45 +++++ .../Controls/IntegrationClientViewModel.cs | 43 +++++ 7 files changed, 309 insertions(+), 56 deletions(-) create mode 100644 src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoLocalIntegrationsService.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Models/ConsentModels.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/IntegrationClientViewModel.cs diff --git a/src/Platforms/SecureFolderFS.UI/Constants.cs b/src/Platforms/SecureFolderFS.UI/Constants.cs index d15f23a5e..8dae42be0 100644 --- a/src/Platforms/SecureFolderFS.UI/Constants.cs +++ b/src/Platforms/SecureFolderFS.UI/Constants.cs @@ -19,6 +19,7 @@ public static class FileNames public const string KEY_FILE_EXTENSION = ".key"; public const string VAULT_SHORTCUT_FILE_EXTENSION = ".sfvault"; public const string ICON_ASSET_PATH = "Assets/AppAssets/app_icon.ico"; + public const string API_CLIENTS_FILENAME = "api_clients.json"; public static class Accounts { diff --git a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs index 9d1106d39..5413a6652 100644 --- a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; @@ -14,10 +15,13 @@ using Microsoft.UI.Xaml.Media; using Microsoft.Windows.AppLifecycle; using OwlCore.Storage; +using SecureFolderFS.Sdk.Api.Services; using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.DataModels; using SecureFolderFS.Sdk.Messages; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels; +using SecureFolderFS.Sdk.ViewModels.Controls.VaultList; using SecureFolderFS.Sdk.ViewModels.Views.Host; using SecureFolderFS.Sdk.ViewModels.Views.Root; using SecureFolderFS.Shared; @@ -28,6 +32,7 @@ using SecureFolderFS.Storage.VirtualFileSystem; using SecureFolderFS.UI; using SecureFolderFS.UI.Helpers; +using SecureFolderFS.Uno.ServiceImplementation; using SecureFolderFS.Uno.UserControls.InterfaceRoot; using Uno.Extensions; using Uno.UI; @@ -51,6 +56,15 @@ public partial class App : Application { public static App? Instance { get; private set; } + /// + /// Tracks the vault preview windows currently open, keyed by vault identifier. + /// + /// + /// Only ever touched on the UI thread. Used to collapse repeated unlock requests onto the window + /// that is already showing, rather than stacking a new one for each request. + /// + private readonly Dictionary _openPreviewWindows = new(); + public bool UseForceClose { get; set; } public IServiceProvider? ServiceProvider { get; private set; } @@ -159,21 +173,23 @@ await SafetyHelpers.NoFailureAsync(async () => // Prepare MainWindow EnsureMainWindow(MainWindow, MainViewModel); + // Connect the local integration API to live vault state + _ = StartLocalIntegrationApiAsync(MainViewModel); + #if WINDOWS // Check if the app was launched via file activation (shortcut file) var isShortcutActivation = IsShortcutFileActivation(Program.InitialActivationArgs); - var isUriActivation = IsUriActivation(Program.InitialActivationArgs); var isStartupActivation = IsStartupActivation(Program.InitialActivationArgs); // Activate MainWindow (required for initialization) MainWindow.Activate(); // If launched via shortcut file or on system startup, hide the main window immediately - if (isShortcutActivation || isUriActivation || isStartupActivation) + if (isShortcutActivation || isStartupActivation) MainWindow.Hide(enableEfficiencyMode: false); // Show the auto-unlock vault prompt, unless another activation already presents vault UI - if (!isShortcutActivation && !isUriActivation) + if (!isShortcutActivation) _ = ShowAutoUnlockVaultAsync(); // Process initial file activation if the app was launched via file association @@ -208,11 +224,6 @@ private static bool IsShortcutFileActivation(AppActivationArguments? args) storageFile.Path.EndsWith(UI.Constants.FileNames.VAULT_SHORTCUT_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase); } - private static bool IsUriActivation(AppActivationArguments? args) - { - return args is { Kind: ExtendedActivationKind.Protocol, Data: IProtocolActivatedEventArgs }; - } - /// /// Checks if the app was launched on system startup, in which case it should start in the background (System Tray). /// @@ -243,13 +254,6 @@ public async Task OnActivatedAsync(AppActivationArguments args) await HandleVaultShortcutActivationAsync(storageFile.Path); } - else if (args.Kind == ExtendedActivationKind.Protocol) - { - if (args.Data is not IProtocolActivatedEventArgs protocolArgs) - return; - - await HandleUriActivationAsync(protocolArgs.Uri); - } } /// @@ -259,36 +263,56 @@ public async Task OnActivatedAsync(AppActivationArguments args) public async Task HandleVaultShortcutActivationAsync(string filePath) { var shortcutFile = new SystemFileEx(filePath); - await using var shortcutStream = await shortcutFile.OpenReadAsync(default); + await using var shortcutStream = await shortcutFile.OpenReadAsync(); - var shortcutData = await SerializationExtensions.DeserializeAsync(StreamSerializer.Instance, shortcutStream); + var shortcutData = await StreamSerializer.Instance.DeserializeAsync(shortcutStream); if (shortcutData?.PersistableId is null) return; await HandleVaultPreviewActivationAsync(shortcutData.PersistableId); } - private async Task HandleVaultPreviewActivationAsync(string persistableId) + private async Task HandleVaultPreviewActivationAsync(string persistableId) { if (MainViewModel is null) - return; + return false; await MainWindowInitialized.Task; var listItemViewModel = MainViewModel.VaultListViewModel.Items.FirstOrDefault(x => x.VaultViewModel.VaultModel.DataModel.PersistableId == persistableId); if (listItemViewModel is null) - return; + return false; var vaultViewModel = listItemViewModel.VaultViewModel; + return await ShowVaultPreviewWindowAsync(persistableId, listItemViewModel, vaultViewModel); + } + + /// + /// Shows the vault preview window, or surfaces the one already open for that vault. + /// + /// A that represents the asynchronous operation. Value is if a new window was shown; otherwise, . + private async Task ShowVaultPreviewWindowAsync( + string persistableId, + VaultListItemViewModel listItemViewModel, + VaultViewModel vaultViewModel) + { + var shown = false; await MainWindowSynchronizationContext.PostOrExecuteAsync(async () => { - if (MainViewModel.RootNavigationService.CurrentView is not MainHostViewModel mainHostViewModel) + if (MainViewModel?.RootNavigationService.CurrentView is not MainHostViewModel mainHostViewModel) return; + // A prompt for this vault is already up. Bring it forward instead of opening another + if (_openPreviewWindows.TryGetValue(persistableId, out var existingWindow)) + { + existingWindow.Activate(); + return; + } + // Creating a window while the main window is still running its first layout/render pass - // segfaults the macOS Skia host, so this must only ever run once the main window has settled - // (see the remarks on MainWindowInitialized). + // segfaults the macOS Skia host, so this must only ever run once the main window has settled. + // (see the remarks on MainWindowInitialized) var window = new Window(); window.Closed += PreviewWindow_Closed; @@ -310,16 +334,22 @@ await MainWindowSynchronizationContext.PostOrExecuteAsync(async () => window.AppWindow.MoveAndResize(new(100, 100, 464, 640)); #endif + _openPreviewWindows[persistableId] = window; + shown = true; + await vaultPreviewViewModel.InitAsync(); window.Activate(); }); - static void PreviewWindow_Closed(object sender, WindowEventArgs args) + return shown; + + void PreviewWindow_Closed(object sender, WindowEventArgs args) { if (sender is not Window window) return; window.Closed -= PreviewWindow_Closed; + _openPreviewWindows.Remove(persistableId); (window.Content as VaultPreviewRootControl)?.ViewModel?.Dispose(); } } @@ -340,52 +370,72 @@ private async Task ShowAutoUnlockVaultAsync() await HandleVaultPreviewActivationAsync(autoUnlockVaultId); } - private async Task HandleVaultLockActivationAsync(string persistableId) + /// + /// Connects the local integration API to live state, and starts it if the user enabled it. + /// + /// The main view model. + /// A that represents the asynchronous operation. + /// + /// The bridge is attached regardless, so that toggling integrations on later needs no restart. + /// Starting the endpoint is separate and strictly opt-in. + /// + private async Task StartLocalIntegrationApiAsync(MainViewModel mainViewModel) { - if (MainViewModel is null) - return; - + // The vault list must be populated before the first snapshot is taken await MainWindowInitialized.Task; - var listItemViewModel = MainViewModel.VaultListViewModel.Items.FirstOrDefault(x => - x.VaultViewModel.VaultModel.DataModel.PersistableId == persistableId); - if (listItemViewModel is null) - return; + try + { + var bridge = DI.Service(); + if (bridge is not UnoVaultApiBridge implementation) + return; - var vaultViewModel = listItemViewModel.VaultViewModel; - if (!vaultViewModel.IsUnlocked) - return; + // Load the pairing store before the bridge derives any public vault identifiers from it + await DI.Service().InitAsync(); - await MainWindowSynchronizationContext.PostOrExecuteAsync(async () => + implementation.ShowUnlockPromptAsync = ShowUnlockPromptForApiAsync; + implementation.ShowMainWindow = ShowMainWindowForApiAsync; + implementation.Attach(mainViewModel, MainWindowSynchronizationContext); + + var settingsService = DI.Service(); + var apiHost = DI.Service(); + + if (settingsService.UserSettings.EnableLocalIntegrations) + await apiHost.StartAsync(); + else + apiHost.PublishDisabled(); + } + catch (Exception ex) { - WeakReferenceMessenger.Default.Send(new VaultLockRequestedMessage(vaultViewModel.VaultModel)); - }); + // A failure here must never prevent the app from running normally + ApplicationLifecycle.LogException(ex); + } } /// - /// Handles URI protocol activation (e.g. sffs://vault/preview?id=...). + /// Shows the unlock prompt on behalf of an integration. /// - public async Task HandleUriActivationAsync(Uri uri) + /// The view model to show the unlock window for. + /// A that represents the asynchronous operation. Value is if a prompt was raised; otherwise, . + private async Task ShowUnlockPromptForApiAsync(VaultViewModel vaultViewModel) { - if (!uri.Host.Equals("vault", StringComparison.OrdinalIgnoreCase)) - return; + var persistableId = vaultViewModel.VaultModel.DataModel.PersistableId; + if (string.IsNullOrEmpty(persistableId)) + return false; - var query = System.Web.HttpUtility.ParseQueryString(uri.Query); - var persistableId = query["id"]; - if (persistableId is null) - return; + return await HandleVaultPreviewActivationAsync(persistableId); + } - var action = uri.AbsolutePath.Trim('/'); - switch (action) + /// + /// Brings the main window to the foreground on behalf of an integration. + /// + private Task ShowMainWindowForApiAsync() + { + return MainWindowSynchronizationContext.PostOrExecuteAsync(() => { - case "preview": - await HandleVaultPreviewActivationAsync(persistableId); - break; - - case "lock": - await HandleVaultLockActivationAsync(persistableId); - break; - } + MainWindow?.Activate(); + return Task.CompletedTask; + }); } #region Window Configuration diff --git a/src/Platforms/SecureFolderFS.Uno/Extensions/UnoIocExtensions.cs b/src/Platforms/SecureFolderFS.Uno/Extensions/UnoIocExtensions.cs index cfa31d443..aa0f45461 100644 --- a/src/Platforms/SecureFolderFS.Uno/Extensions/UnoIocExtensions.cs +++ b/src/Platforms/SecureFolderFS.Uno/Extensions/UnoIocExtensions.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using OwlCore.Storage; +using SecureFolderFS.Sdk.Api.Services; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Models; using SecureFolderFS.UI.ServiceImplementation; using SecureFolderFS.UI.ServiceImplementation.Settings; using SecureFolderFS.Uno.ServiceImplementation; @@ -14,6 +16,7 @@ internal static class UnoIocExtensions public static IServiceCollection WithUnoServices(this IServiceCollection serviceCollection, IModifiableFolder settingsFolder) { return serviceCollection + .WithLocalIntegrationApi(settingsFolder) .Foundation(AddService.AddSingleton, _ => new(new AppSettings(settingsFolder), new UserSettings(settingsFolder))) .Foundation(AddService.AddSingleton) .Foundation(AddService.AddSingleton) @@ -24,5 +27,22 @@ public static IServiceCollection WithUnoServices(this IServiceCollection service .Foundation(AddService.AddTransient) ; } + + private static IServiceCollection WithLocalIntegrationApi(this IServiceCollection serviceCollection, IModifiableFolder settingsFolder) + { + return serviceCollection + .Foundation(AddService.AddSingleton, _ => new PairingStore(UI.Constants.FileNames.API_CLIENTS_FILENAME, settingsFolder, StreamSerializer.Instance)) + .Foundation(AddService.AddSingleton, sp => new UnoVaultApiBridge(sp.GetRequiredService())) + .Foundation(AddService.AddSingleton) + .Foundation(AddService.AddSingleton) + .Foundation(AddService.AddSingleton, sp => new ApiHost( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService().AppVersion.ToString())) + .Foundation(AddService.AddSingleton) + ; + } } } diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoLocalIntegrationsService.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoLocalIntegrationsService.cs new file mode 100644 index 000000000..0ce9822c2 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoLocalIntegrationsService.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Api.Services; +using SecureFolderFS.Sdk.Models; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared.Extensions; + +namespace SecureFolderFS.Uno.ServiceImplementation +{ + /// + public sealed class UnoLocalIntegrationsService : ILocalIntegrationsService + { + private readonly IApiHost _apiHost; + private readonly IPairingStore _pairingStore; + private readonly ISettingsService _settingsService; + + public UnoLocalIntegrationsService(IApiHost apiHost, IPairingStore pairingStore, ISettingsService settingsService) + { + _apiHost = apiHost; + _pairingStore = pairingStore; + _settingsService = settingsService; + } + + /// + public async Task SetEnabledAsync(bool isEnabled, CancellationToken cancellationToken = default) + { + _settingsService.UserSettings.EnableLocalIntegrations = isEnabled; + await _settingsService.UserSettings.TrySaveAsync(cancellationToken); + + if (isEnabled) + { + await _apiHost.StartAsync(cancellationToken); + return; + } + + await _apiHost.StopAsync(cancellationToken); + + // Recorded in the endpoint file so a client can tell "switched off" apart from "not running", + // and explain to its user why nothing is showing. + _apiHost.PublishDisabled(); + } + + /// + public IReadOnlyList GetClients() + { + return _pairingStore.GetClients() + .Select(x => new IntegrationClientInfo( + x.Id, + x.DisplayName, + x.WasIdentityVerified, + x.Signer, + x.ExecutablePath, + x.LastUsedAt)) + .ToArray(); + } + + /// + public Task RevokeClientAsync(string clientId, CancellationToken cancellationToken = default) + => _pairingStore.RevokeClientAsync(clientId, cancellationToken); + + /// + public Task RevokeAllClientsAsync(CancellationToken cancellationToken = default) + => _pairingStore.RevokeAllAsync(cancellationToken); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs b/src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs new file mode 100644 index 000000000..94a30048d --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs @@ -0,0 +1,27 @@ +using System; +using SecureFolderFS.Sdk.Api.Protocol; +using SecureFolderFS.Sdk.Api.Enums; + +namespace SecureFolderFS.Sdk.Api.EventArguments +{ + /// + /// Event arguments for a vault change. + /// + public sealed class ApiVaultChangedEventArgs(ApiVaultChangeKind kind, string vaultId, VaultInfo? vault) : EventArgs + { + /// + /// Gets the kind of change that occurred. + /// + public ApiVaultChangeKind Kind { get; } = kind; + + /// + /// Gets the public identifier of the affected vault. + /// + public string VaultId { get; } = vaultId; + + /// + /// Gets the vault's new state, or when it was removed. + /// + public VaultInfo? Vault { get; } = vault; + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Models/ConsentModels.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ConsentModels.cs new file mode 100644 index 000000000..737c830ed --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ConsentModels.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace SecureFolderFS.Sdk.Api.Models +{ + /// + /// Describes what the operating system could actually prove about a connecting process. + /// + /// Whether the platform cryptographically verified the publisher. Always false on macOS and Linux. + /// The path of the connecting executable, when obtainable. + /// The verified publisher, present only when is true. + public sealed record PeerEvidence( + bool IsVerified, + string? ExecutablePath = null, + string? Signer = null) + { + /// + /// Gets an instance representing a peer about which nothing could be determined. + /// + public static PeerEvidence Unknown { get; } = new(IsVerified: false); + } + + /// + /// A request to let an application connect. + /// + /// The client-supplied display name. Untrusted, so always shown next to . + /// What the OS could independently determine about the caller. + /// The permissions being asked for. + public sealed record ApiConsentRequest( + string ClientName, + PeerEvidence Evidence, + IReadOnlyList RequestedScopes); + + /// + /// The user's answer to a consent prompt. + /// + /// Whether the user allowed the connection. + /// The permissions the user actually granted. + public sealed record ApiConsentResult(bool IsGranted, IReadOnlyList GrantedScopes) + { + /// + /// Gets a result representing a refusal. + /// + public static ApiConsentResult Denied { get; } = new(false, []); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/IntegrationClientViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/IntegrationClientViewModel.cs new file mode 100644 index 000000000..0d1f69345 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/IntegrationClientViewModel.cs @@ -0,0 +1,43 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Models; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Shared; + +namespace SecureFolderFS.Sdk.ViewModels.Controls +{ + [Bindable(true)] + [Inject] + public sealed partial class IntegrationClientViewModel : ObservableObject + { + [ObservableProperty] private string? _DisplayName; + [ObservableProperty] private string? _ExecutablePath; + [ObservableProperty] private string? _IdentityDescription; + [ObservableProperty] private string? _LastUsedDescription; + + public IAsyncRelayCommand RevokeCommand { get; } + + /// + /// Gets the underlying pairing information. + /// + public IntegrationClientInfo ClientInfo { get; } + + public IntegrationClientViewModel(IntegrationClientInfo clientInfo, IAsyncRelayCommand revokeCommand) + { + ServiceProvider = DI.Default; + ClientInfo = clientInfo; + RevokeCommand = revokeCommand; + DisplayName = ClientInfo.DisplayName; + ExecutablePath = ClientInfo.ExecutablePath; + IdentityDescription = ClientInfo.IsIdentityVerified && !string.IsNullOrEmpty(ClientInfo.Signer) + ? "ApiConsentVerifiedPublisher".ToLocalized(ClientInfo.Signer) + : "ApiClientUnverified".ToLocalized(); + LastUsedDescription = ClientInfo.LastUsedAt is { } lastUsed + ? LocalizationService.LocalizeDate(lastUsed.LocalDateTime) + : "ApiClientNeverUsed".ToLocalized(); + } + } +} From 500c5833b61e39c3916aa70c1577e649f31271ef Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:51:05 +0200 Subject: [PATCH 5/9] Added property optionality parameter to SourceGen --- .../Helpers/PublicVaultId.cs | 37 ++++++ .../Policy/ApiPolicy.cs | 117 ++++++++++++++++++ .../SecureFolderFS.Sdk.Api.csproj | 1 + .../Services/IPeerEvidenceProvider.cs | 16 +++ .../Services/IVaultApiBridge.cs | 65 ++++++++++ .../Transport/IApiTransport.cs | 89 +++++++++++++ .../Attributes/InjectAttribute.cs | 5 + .../Settings/PreferencesSettingsViewModel.cs | 82 +++++++++++- .../Helpers/SourceGeneratorHelpers.cs | 18 +-- .../InjectGenerator.cs | 15 ++- 10 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/IPeerEvidenceProvider.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Transport/IApiTransport.cs diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs new file mode 100644 index 000000000..13384431f --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs @@ -0,0 +1,37 @@ +using System; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; + +namespace SecureFolderFS.Sdk.Api.Helpers +{ + /// + /// Derives the public identifier under which a vault is exposed to integrations, so third-party + /// configuration never comes to depend on the app's internal persistence identifier. + /// + public static class PublicVaultId + { + // 16 digest bytes equating to a 128-bit identifier + private const int ID_BYTE_LENGTH = 16; + + /// + /// Computes the public identifier for a vault. + /// + /// The per-installation secret held by the pairing store. + /// The application's internal identifier for the vault. + [SkipLocalsInit] + public static string Compute(ReadOnlySpan installSecret, string persistableId) + { + Span digest = stackalloc byte[32]; + HMACSHA256.HashData(installSecret, Encoding.UTF8.GetBytes(persistableId), digest); + + return ToBase64Url(digest[..ID_BYTE_LENGTH]); + } + + /// + /// Encodes bytes using the URL-safe Base64 alphabet without padding. + /// + internal static string ToBase64Url(ReadOnlySpan bytes) + => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs new file mode 100644 index 000000000..87ab2783c --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using SecureFolderFS.Sdk.Api.Enums; + +namespace SecureFolderFS.Sdk.Api.Policy +{ + /// + /// Enforces per-client request budgets using a token bucket per request class. + /// + public sealed class ApiRateLimiter + { + private readonly Lock _lock = new(); + private readonly Dictionary> _clients = []; + + /// + /// Consumes budget for a request, if any remains. + /// + /// Identifies the caller across connections. + /// Determines which budget applies. + /// Whether the request is allowed, and how long to wait when it is not. + public (bool IsAllowed, int RetryAfterMs) Check(string clientKey, ApiRequestType requestType) + { + var now = DateTimeOffset.UtcNow; + var (capacity, refillInterval) = GetBudget(requestType); + + lock (_lock) + { + if (!_clients.TryGetValue(clientKey, out var buckets)) + _clients[clientKey] = buckets = []; + + if (!buckets.TryGetValue(requestType, out var bucket)) + buckets[requestType] = bucket = new Bucket(capacity, now); + + bucket.Refill(now, capacity, refillInterval); + + if (bucket.Tokens >= 1d) + { + bucket.Tokens--; + return (true, 0); + } + + var retryAfter = refillInterval * (1d - bucket.Tokens); + return (false, (int)retryAfter.TotalMilliseconds); + } + } + + private static (double Capacity, TimeSpan RefillInterval) GetBudget(ApiRequestType requestType) => requestType switch + { + // Enumeration is invisible to the user, so the budget only needs to stop runaway polling. + ApiRequestType.Read => (60d, TimeSpan.FromSeconds(1)), + + // Roughly five prompts a minute, with a short burst allowance for legitimate rapid use. + ApiRequestType.Trigger => (5d, TimeSpan.FromSeconds(12)), + + // One consent prompt at a time, and no rapid retries after a refusal. + ApiRequestType.Pairing => (1d, TimeSpan.FromSeconds(60)), + + _ => throw new ArgumentOutOfRangeException(nameof(requestType)) + }; + + private sealed class Bucket(double tokens, DateTimeOffset lastRefill) + { + private DateTimeOffset _lastRefill = lastRefill; + + public double Tokens { get; set; } = tokens; + + public void Refill(DateTimeOffset now, double capacity, TimeSpan refillInterval) + { + var elapsed = now - _lastRefill; + if (elapsed <= TimeSpan.Zero) + return; + + Tokens = Math.Min(capacity, Tokens + elapsed / refillInterval); + _lastRefill = now; + } + } + } + + /// + /// Collapses duplicate in-flight requests so identical work is never started twice. While a request + /// for a given key is outstanding, further requests for the same key are refused entry. + /// + public sealed class RequestCoalescer + { + private readonly Lock _lock = new(); + private readonly HashSet _inFlight = []; + + /// + /// Attempts to claim exclusive ownership of a unit of work. + /// + /// A scope that releases the claim when disposed, or when the + /// same work is already in flight. + public IDisposable? TryBeginScope(string key) + { + lock (_lock) + { + if (!_inFlight.Add(key)) + return null; + } + + return new Scope(this, key); + } + + private void End(string key) + { + lock (_lock) + _inFlight.Remove(key); + } + + private sealed class Scope(RequestCoalescer owner, string key) : IDisposable + { + /// + public void Dispose() => owner.End(key); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj b/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj index fe26f5b96..e6628d691 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj +++ b/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj @@ -5,6 +5,7 @@ latest enable disable + true diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPeerEvidenceProvider.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPeerEvidenceProvider.cs new file mode 100644 index 000000000..fecac9bc6 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPeerEvidenceProvider.cs @@ -0,0 +1,16 @@ +using SecureFolderFS.Sdk.Api.Transport; +using SecureFolderFS.Sdk.Api.Models; + +namespace SecureFolderFS.Sdk.Api.Services +{ + /// + /// Gathers best-effort identity information about a connecting process. + /// + public interface IPeerEvidenceProvider + { + /// + /// Describes the process on the other end of a connection, or . + /// + PeerEvidence Describe(ApiPeerHandle peer); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs new file mode 100644 index 000000000..90bebe4ff --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Api.Protocol; +using SecureFolderFS.Sdk.Api.Enums; +using SecureFolderFS.Sdk.Api.EventArguments; + +namespace SecureFolderFS.Sdk.Api.Services +{ + /// + /// The single seam through which the API observes and affects live application state, keeping the + /// protocol server free of any dependency on the app or its UI framework. Implementations marshal to + /// the UI thread. + /// + public interface IVaultApiBridge + { + /// + /// Gets whether the application has initialized to serve requests. + /// + bool IsAvailable { get; } + + /// + /// Occurs when a vault is added, removed, renamed, unlocked or locked. + /// + event EventHandler? VaultChanged; + + /// + /// Gets every vault the application currently knows about. + /// + /// A collection of available vaults. + IReadOnlyList GetVaults(); + + /// + /// Asks the application to show its own unlock prompt for a vault. + /// + /// The identifier for the vault to unlock. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the outcome of the unlock attempt. + Task RequestUnlockAsync(string vaultId, CancellationToken cancellationToken = default); + + /// + /// Locks an unlocked vault. + /// + /// The identifier for the vault to lock. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the outcome of the lock attempt. + Task LockAsync(string vaultId, CancellationToken cancellationToken = default); + + /// + /// Reveals the mounted root of an unlocked vault in the system file manager. + /// + /// The identifier for the vault to reveal. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the outcome of the reveal attempt. + Task RevealAsync(string vaultId, CancellationToken cancellationToken = default); + + /// + /// Brings the main application window to the foreground. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the outcome of the bring-to-foreground attempt. + Task ShowMainWindowAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Transport/IApiTransport.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/IApiTransport.cs new file mode 100644 index 000000000..50faefc6b --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/IApiTransport.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; +using SecureFolderFS.Sdk.Api.Enums; + +namespace SecureFolderFS.Sdk.Api.Transport +{ + /// + /// Carries the platform handles needed to identify the process on the other end of a connection. + /// + public sealed class ApiPeerHandle + { + /// + /// Gets the transport that produced this peer. + /// + public ApiTransportKind Kind { get; } + + /// + /// Gets the server-side pipe handle, when the transport is a named pipe. + /// + public SafePipeHandle? PipeHandle { get; } + + /// + /// Gets the accepted socket, when the transport is a Unix domain socket. + /// + public Socket? Socket { get; } + + public ApiPeerHandle(SafePipeHandle pipeHandle) + { + Kind = ApiTransportKind.NamedPipe; + PipeHandle = pipeHandle; + } + + public ApiPeerHandle(Socket socket) + { + Kind = ApiTransportKind.UnixSocket; + Socket = socket; + } + } + + /// + /// One accepted client connection. + /// + public interface IApiConnection : IAsyncDisposable + { + /// + /// Gets the duplex byte stream carrying newline-delimited JSON. + /// + Stream Stream { get; } + + /// + /// Gets the handles used to gather evidence about the connecting process. + /// + ApiPeerHandle Peer { get; } + } + + /// + /// Listens for local client connections on one platform IPC mechanism. + /// + public interface IApiTransport : IAsyncDisposable + { + /// + /// Gets the transport mechanism this instance implements. + /// + ApiTransportKind Kind { get; } + + /// + /// Gets the address clients connect to, as advertised in the endpoint file. + /// + string Address { get; } + + /// + /// Binds the endpoint and begins listening. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task StartAsync(CancellationToken cancellationToken = default); + + /// + /// Waits for the next client to connect. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the accepted connection. + Task AcceptAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk/Attributes/InjectAttribute.cs b/src/Sdk/SecureFolderFS.Sdk/Attributes/InjectAttribute.cs index fe86395a7..a30ddab55 100644 --- a/src/Sdk/SecureFolderFS.Sdk/Attributes/InjectAttribute.cs +++ b/src/Sdk/SecureFolderFS.Sdk/Attributes/InjectAttribute.cs @@ -15,5 +15,10 @@ public sealed class InjectAttribute : Attribute /// Gets or sets the value that represents the visibility of the injected class. /// public string? Visibility { get; set; } + + /// + /// Gets or sets the value that represents the optionality of the injected class. + /// + public string? Optionality { get; set; } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs index ef1e20c2d..044478c13 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs @@ -1,28 +1,53 @@ -using SecureFolderFS.Sdk.Attributes; +using CommunityToolkit.Mvvm.Input; +using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Controls; using SecureFolderFS.Sdk.ViewModels.Controls.Banners; using SecureFolderFS.Shared; using SecureFolderFS.Shared.Helpers; +using System; +using System.Collections.ObjectModel; using System.ComponentModel; using System.Threading; using System.Threading.Tasks; namespace SecureFolderFS.Sdk.ViewModels.Views.Settings { - [Inject] + [Inject, Inject(Optionality = "optional")] [Bindable(true)] public sealed partial class PreferencesSettingsViewModel : BaseSettingsViewModel { public FileSystemBannerViewModel BannerViewModel { get; } + public bool AreIntegrationsSupported { get; } + + /// + /// Gets the applications currently authorized to use the local integration API. + /// + public ObservableCollection ConnectedApps { get; } + public PreferencesSettingsViewModel() { ServiceProvider = DI.Default; BannerViewModel = new(); + ConnectedApps = new(); + AreIntegrationsSupported = LocalIntegrationsService is not null; Title = "SettingsPreferences".ToLocalized(); } + public bool EnableLocalIntegrations + { + get => UserSettings.EnableLocalIntegrations; + set + { + if (UserSettings.EnableLocalIntegrations == value) + return; + + _ = ApplyIntegrationsAsync(value); + } + } + public bool StartOnSystemStartup { get => UserSettings.StartOnSystemStartup; @@ -83,7 +108,9 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau { await BannerViewModel.InitAsync(cancellationToken); - // Reflect auto start changes made outside the app (e.g. in system settings) + RefreshConnectedApps(); + + // Reflect auto-start changes made outside the app (e.g., in system settings) var isAutoStartEnabled = await SafetyHelpers.NoFailureAsync(async () => await SystemService.IsAutoStartEnabledAsync(cancellationToken)); if (UserSettings.StartOnSystemStartup != isAutoStartEnabled) { @@ -92,6 +119,55 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau } } + [RelayCommand] + private async Task RevokeAllAppsAsync() + { + if (LocalIntegrationsService is null) + return; + + await LocalIntegrationsService.RevokeAllClientsAsync(); + ConnectedApps.Clear(); + } + + [RelayCommand] + private async Task RevokeAppAsync(IntegrationClientViewModel client, CancellationToken cancellationToken) + { + if (LocalIntegrationsService is null) + return; + + await LocalIntegrationsService.RevokeClientAsync(client.ClientInfo.Id, cancellationToken); + ConnectedApps.Remove(client); + } + + private async Task ApplyIntegrationsAsync(bool isEnabled) + { + if (LocalIntegrationsService is null) + return; + + try + { + await LocalIntegrationsService.SetEnabledAsync(isEnabled); + } + catch (Exception) + { + // The endpoint could not be bound, so leave the setting reflecting what actually happened + UserSettings.EnableLocalIntegrations = !isEnabled; + } + + OnPropertyChanged(nameof(EnableLocalIntegrations)); + RefreshConnectedApps(); + } + + private void RefreshConnectedApps() + { + ConnectedApps.Clear(); + if (LocalIntegrationsService is null) + return; + + foreach (var client in LocalIntegrationsService.GetClients()) + ConnectedApps.Add(new IntegrationClientViewModel(client, RevokeAppCommand)); + } + private async Task ApplyAutoStartAsync(bool isEnabled) { var isApplied = await SafetyHelpers.NoFailureAsync(async () => await SystemService.TrySetAutoStartAsync(isEnabled)); diff --git a/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs b/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs index 52d1f650c..524b74504 100644 --- a/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs +++ b/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs @@ -20,13 +20,15 @@ internal static class SourceGeneratorHelpers /// /// /// - internal static ExpressionSyntax GetLoggerRegistration(string containingTypeName, string serviceProviderName) + internal static ExpressionSyntax GetLoggerRegistration(string containingTypeName, string serviceProviderName, bool isRequired) { // ServiceProviderServiceExtensions.GetRequiredService(this.ServiceProvider) + // or + // ServiceProviderServiceExtensions.GetService(this.ServiceProvider) var getLoggerFactory = InvocationExpression( MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName("global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions"), - GenericName("GetRequiredService").WithTypeArgumentList( + GenericName(isRequired ? "GetRequiredService" : "GetService").WithTypeArgumentList( TypeArgumentList(SeparatedList().Add( ParseTypeName("global::Microsoft.Extensions.Logging.ILoggerFactory")))))) .AddArgumentListArguments(Argument(GetThisMemberAccessExpression(serviceProviderName))); @@ -58,8 +60,8 @@ internal static AccessorDeclarationSyntax GetGetter() => /// /// /// - internal static PropertyDeclarationSyntax GetPropertyDeclaration(SyntaxKind visibility, string propertyName, string type, params AccessorDeclarationSyntax[] accessors) => - PropertyDeclaration(ParseTypeName(type), propertyName) + internal static PropertyDeclarationSyntax GetPropertyDeclaration(SyntaxKind visibility, string propertyName, string type, bool isNullable, params AccessorDeclarationSyntax[] accessors) => + PropertyDeclaration(ParseNullableType(type, isNullable), propertyName) .AddModifiers(Token(visibility)) .AddAccessorListAccessors(accessors); @@ -70,8 +72,8 @@ internal static PropertyDeclarationSyntax GetPropertyDeclaration(SyntaxKind visi /// /// /// - internal static PropertyDeclarationSyntax GetPropertyDeclaration(SyntaxKind visibility, string propertyName, string type) => - PropertyDeclaration(ParseTypeName(type), propertyName) + internal static PropertyDeclarationSyntax GetPropertyDeclaration(SyntaxKind visibility, string propertyName, string type, bool isNullable) => + PropertyDeclaration(ParseNullableType(type, isNullable), propertyName) .AddModifiers(Token(visibility)); /// @@ -162,10 +164,10 @@ internal static CompilationUnitSyntax GetCompilationUnit(MemberDeclarationSyntax /// /// /// - internal static ExpressionSyntax GetServiceRegistration(ITypeSymbol injectionType, string serviceProviderName) => + internal static ExpressionSyntax GetServiceRegistration(ITypeSymbol injectionType, string serviceProviderName, bool isRequired) => InvocationExpression(MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName("global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions"), - GenericName("GetRequiredService").WithTypeArgumentList(TypeArgumentList(SeparatedList().Add(ParseTypeName(injectionType.ToDisplayString())))))) + GenericName(isRequired ? "GetRequiredService" : "GetService").WithTypeArgumentList(TypeArgumentList(SeparatedList().Add(ParseTypeName(injectionType.ToDisplayString())))))) .AddArgumentListArguments(Argument(GetThisMemberAccessExpression(serviceProviderName))); /// diff --git a/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs b/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs index 292a2b985..a212dc166 100644 --- a/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs +++ b/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs @@ -22,7 +22,7 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator var members = new List(); var getter = GetGetter(); - var serviceProviderProperty = GetPropertyDeclaration(SyntaxKind.PrivateKeyword, Constants.ServiceProviderName, Constants.ServiceProviderNamespace, getter) + var serviceProviderProperty = GetPropertyDeclaration(SyntaxKind.PrivateKeyword, Constants.ServiceProviderName, Constants.ServiceProviderNamespace, false, getter) .AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(SecureFolderFS))); members.Add(serviceProviderProperty); @@ -31,6 +31,7 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator { var name = string.Empty; var visibility = SyntaxKind.None; + var isRequired = true; if (attribute.AttributeClass is not { TypeArguments: [var type, ..] }) return null; @@ -48,6 +49,10 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator case "Visibility": visibility = GetVisibility((string)value); break; + + case "Optionality": + isRequired = (string)value != "optional"; + break; } } } @@ -64,11 +69,11 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator var loggerTypeName = $"global::Microsoft.Extensions.Logging.ILogger<{containingTypeName}>"; var loggerField = GetFieldDeclaration(SyntaxKind.PrivateKeyword, backingFieldName, loggerTypeName, true); - var loggerProperty = GetPropertyDeclaration(visibility, name, loggerTypeName).WithExpressionBody( + var loggerProperty = GetPropertyDeclaration(visibility, name, loggerTypeName, !isRequired).WithExpressionBody( ArrowExpressionClause( AssignmentExpression(SyntaxKind.CoalesceAssignmentExpression, GetThisMemberAccessExpression(backingFieldName), - GetLoggerRegistration(containingTypeName, Constants.ServiceProviderName)))) + GetLoggerRegistration(containingTypeName, Constants.ServiceProviderName, isRequired)))) .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) .AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(InjectGenerator))); @@ -81,11 +86,11 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator var backingFieldName = $"_{name}"; var injecteeField = GetFieldDeclaration(SyntaxKind.PrivateKeyword, backingFieldName, type.ToDisplayString(), true); - var injecteeProperty = GetPropertyDeclaration(visibility, name, type.ToDisplayString()).WithExpressionBody( + var injecteeProperty = GetPropertyDeclaration(visibility, name, type.ToDisplayString(), !isRequired).WithExpressionBody( ArrowExpressionClause( AssignmentExpression(SyntaxKind.CoalesceAssignmentExpression, GetThisMemberAccessExpression(backingFieldName), - GetServiceRegistration(type, Constants.ServiceProviderName)))) + GetServiceRegistration(type, Constants.ServiceProviderName, isRequired)))) .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) .AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(InjectGenerator))); From 717cea586b6c54dbc1ab8eeb852c7f95690f278c Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:16:25 +0200 Subject: [PATCH 6/9] Added Named Pipe transport --- .../Transport/NamedPipeApiTransport.cs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Transport/NamedPipeApiTransport.cs diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Transport/NamedPipeApiTransport.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/NamedPipeApiTransport.cs new file mode 100644 index 000000000..2034a6341 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/NamedPipeApiTransport.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Api.Enums; + +namespace SecureFolderFS.Sdk.Api.Transport +{ + /// + /// Serves the API over a Windows named pipe restricted to the current user. + /// + [SupportedOSPlatform("windows")] + public sealed class NamedPipeApiTransport : IApiTransport + { + private readonly string _pipeName; + private readonly PipeSecurity _pipeSecurity; + private NamedPipeServerStream? _pendingInstance; + private bool _disposed; + + /// + public ApiTransportKind Kind => ApiTransportKind.NamedPipe; + + /// + public string Address => $@"\\.\pipe\{_pipeName}"; + + public NamedPipeApiTransport(string pipeName) + { + _pipeName = pipeName; + _pipeSecurity = CreateSecurity(); + } + + /// + public Task StartAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Create the first instance eagerly so the pipe is connectable as soon as the host starts + _pendingInstance ??= CreateInstance(); + return Task.CompletedTask; + } + + /// + public async Task AcceptAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var instance = _pendingInstance ?? CreateInstance(); + _pendingInstance = null; + + try + { + await instance.WaitForConnectionAsync(cancellationToken); + } + catch + { + await instance.DisposeAsync(); + throw; + } + + // Stand up the next instance immediately so the pipe name never briefly disappears + _pendingInstance = CreateInstance(); + + return new NamedPipeConnection(instance); + } + + private NamedPipeServerStream CreateInstance() + { + return NamedPipeServerStreamAcl.Create( + _pipeName, + PipeDirection.InOut, + Constants.Limits.MAX_CONNECTIONS, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + inBufferSize: 0, + outBufferSize: 0, + _pipeSecurity); + } + + private static PipeSecurity CreateSecurity() + { + var security = new PipeSecurity(); + var currentUser = WindowsIdentity.GetCurrent().User + ?? throw new InvalidOperationException("Unable to determine the current user's SID."); + + // Only the user running SecureFolderFS may talk to the pipe + security.AddAccessRule(new PipeAccessRule( + currentUser, + PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance, + AccessControlType.Allow)); + + // Named pipes are reachable remotely over SMB. Deny ACEs (ordered ahead of allow) close that + // path for a remote logon as the same account, without affecting local clients. + security.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.NetworkSid, null), + PipeAccessRights.FullControl, + AccessControlType.Deny)); + + security.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.AnonymousSid, null), + PipeAccessRights.FullControl, + AccessControlType.Deny)); + + return security; + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + if (_pendingInstance is not null) + await _pendingInstance.DisposeAsync(); + + _pendingInstance = null; + } + + private sealed class NamedPipeConnection : IApiConnection + { + private readonly NamedPipeServerStream _pipe; + + /// + public Stream Stream => _pipe; + + /// + public ApiPeerHandle Peer { get; } + + public NamedPipeConnection(NamedPipeServerStream pipe) + { + _pipe = pipe; + Peer = new ApiPeerHandle(pipe.SafePipeHandle); + } + + /// + public async ValueTask DisposeAsync() + { + try + { + if (_pipe.IsConnected) + _pipe.Disconnect(); + } + catch (Exception) + { + // The peer may already be gone. We don't care, only dispose + } + + await _pipe.DisposeAsync(); + } + } + } +} From 932655a8c25f737750a3158595bcdbaa09437dac Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:28:57 +0200 Subject: [PATCH 7/9] Added Unix socket transport --- .../PInvoke/UnsafeNative.Imports.cs | 13 + src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs | 2 + .../Models/PairingModels.cs | 23 ++ .../Services/IPairingStore.cs | 87 ++++++ .../Services/PairingStore.cs | 282 ++++++++++++++++++ .../Transport/UnixSocketApiTransport.cs | 180 +++++++++++ 6 files changed, 587 insertions(+) create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/IPairingStore.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Transport/UnixSocketApiTransport.cs diff --git a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs index ad9f39009..4247d5096 100644 --- a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs +++ b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.InteropServices; using System.Text; +using Microsoft.Win32.SafeHandles; namespace SecureFolderFS.Uno.PInvoke { @@ -30,6 +31,10 @@ internal static partial class UnsafeNative [DllImport("user32.dll", SetLastError = true)] public static extern uint GetDpiForWindow(IntPtr hWnd); + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetNamedPipeClientProcessId(SafePipeHandle pipe, out uint clientProcessId); + [DllImport("comctl32.dll", SetLastError = true)] public static extern bool SetWindowSubclass( IntPtr hWnd, @@ -118,6 +123,11 @@ public delegate IntPtr SUBCLASSPROC( UIntPtr uIdSubclass, IntPtr dwRefData); #endif + +#if !WINDOWS + [LibraryImport("libc", SetLastError = true)] + public static partial int getsockopt(int socket, int level, int optionName, byte[] optionValue, ref int optionLength); +#endif #if __UNO_SKIA_MACOS__ public const string LibObjc = "libobjc.dylib"; @@ -300,6 +310,9 @@ public static partial void CFNotificationCenterRemoveObserver( public static partial void objc_msgSend_void_long_IntPtr_IntPtr(IntPtr receiver, IntPtr selector, long arg1, IntPtr arg2, IntPtr arg3); #endregion + + [LibraryImport("libproc", SetLastError = true)] + public static partial int proc_pidpath(int pid, byte[] buffer, uint bufferSize); public static IntPtr CfString(string value) { diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs index 2d23a6897..dc3d81518 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs @@ -9,6 +9,8 @@ public static class Constants public const string ENDPOINT_FILE_NAME = "api-endpoint.json"; public const string APP_DIRECTORY_NAME = "SecureFolderFS"; public const string XDG_DIRECTORY_NAME = "securefolderfs"; + public const string TRANSPORT_NAMED_PIPE = "namedPipe"; + public const string TRANSPORT_UNIX_SOCKET = "unixSocket"; public static class Methods { diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs index f0c6e4de6..991c06f1b 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs @@ -35,4 +35,27 @@ public sealed record PairedClient( public sealed record DeniedClient( [property: JsonPropertyName("fingerprint")] string Fingerprint, [property: JsonPropertyName("deniedAt")] DateTimeOffset DeniedAt); + + public sealed class PairingStoreDataModel + { + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; set; } = 1; + + [JsonPropertyName("vaultIdKey")] + public string VaultIdKey { get; set; } = string.Empty; + + [JsonPropertyName("clients")] + public List Clients { get; set; } = []; + + [JsonPropertyName("denied")] + public List Denied { get; set; } = []; + + public PairingStoreDataModel Clone() => new() + { + SchemaVersion = SchemaVersion, + VaultIdKey = VaultIdKey, + Clients = [.. Clients], + Denied = [.. Denied] + }; + } } diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPairingStore.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPairingStore.cs new file mode 100644 index 000000000..52abf089a --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPairingStore.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Api.Models; +using SecureFolderFS.Shared.ComponentModel; + +namespace SecureFolderFS.Sdk.Api.Services +{ + /// + /// Persists the applications the user has authorized and the tokens that identify them. + /// + /// + /// Tokens are stored only as SHA-256 digests. An individual token carries 256 bits of generator entropy, + /// so a plain digest is the right construction. + /// + public interface IPairingStore : IPersistable + { + /// + /// Gets the per-installation vault ID key used to derive public vault identifiers. + /// + byte[] VaultIdKey { get; } + + /// + /// Gets how long an application must wait before prompting again after being refused. + /// + TimeSpan DenialCooldown { get; } + + /// + /// Gets every currently paired application. + /// + IReadOnlyList GetClients(); + + /// + /// Finds the pairing a token belongs to, or when unrecognized. + /// + PairedClient? Authenticate(string? token); + + /// + /// Determines whether an application is still within its post-refusal cooldown. + /// + /// The fingerprint to check against. + /// True if is in cooldown; otherwise, false. + bool IsInDenialCooldown(string fingerprint); + + /// + /// Records that a pairing was used, for display in the paired-clients list. + /// + /// The identifier of the pairing. + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task TouchLastUsedAsync(string clientId, CancellationToken cancellationToken = default); + + /// + /// Creates a pairing and issues its token, which is never stored in recoverable form. + /// + /// The display name of the application. + /// The peer evidence of the application. + /// The scopes the application is requesting. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the issued token. + Task CreateClientAsync(string displayName, PeerEvidence evidence, IReadOnlyList scopes, CancellationToken cancellationToken = default); + + /// + /// Removes a pairing, so the application must ask for consent again. + /// + /// The identifier of the pairing. + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task RevokeClientAsync(string clientId, CancellationToken cancellationToken = default); + + /// + /// Removes every pairing and forgets every recorded refusal. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task RevokeAllAsync(CancellationToken cancellationToken = default); + + /// + /// Records that the user refused an application. + /// + /// The fingerprint of the application. + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task RecordDenialAsync(string fingerprint, CancellationToken cancellationToken = default); + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs new file mode 100644 index 000000000..69a8fabc0 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; +using SecureFolderFS.Sdk.Api.Helpers; +using SecureFolderFS.Sdk.Api.Models; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; + +namespace SecureFolderFS.Sdk.Api.Services +{ + /// + public sealed class PairingStore : IPairingStore + { + private readonly string _fileName; + private readonly IModifiableFolder _settingsFolder; + private readonly IAsyncSerializer _serializer; + private readonly Lock _lock = new(); + private readonly SemaphoreSlim _fileLock = new(1, 1); + + private PairingStoreDataModel _data = new(); + private IFile? _file; + private bool _initialized; + + /// + public TimeSpan DenialCooldown { get; } = TimeSpan.FromMinutes(5); + + public PairingStore(string fileName, IModifiableFolder settingsFolder, IAsyncSerializer serializer) + { + _fileName = fileName; + _settingsFolder = settingsFolder; + _serializer = serializer; + } + + /// + public byte[] VaultIdKey + { + get + { + lock (_lock) + { + EnsureInitialized(); + return Convert.FromBase64String(_data.VaultIdKey); + } + } + } + + /// + public IReadOnlyList GetClients() + { + lock (_lock) + return _data.Clients.ToArray(); + } + + /// + public PairedClient? Authenticate(string? token) + { + if (string.IsNullOrEmpty(token)) + return null; + + var candidateHash = ComputeTokenHash(token); + lock (_lock) + { + // Compared in constant time so a caller cannot learn a valid digest by timing rejections + return _data.Clients.FirstOrDefault(x => FixedTimeEqualsBase64(x.TokenHash, candidateHash)); + } + } + + /// + public bool IsInDenialCooldown(string fingerprint) + { + lock (_lock) + { + var entry = _data.Denied.Find(x => x.Fingerprint == fingerprint); + if (entry is null) + return false; + + if (DateTimeOffset.UtcNow - entry.DeniedAt < DenialCooldown) + return true; + + // Expired: drop it from memory; the file is reconciled on the next save + _data.Denied.Remove(entry); + return false; + } + } + + /// + public Task TouchLastUsedAsync(string clientId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + EnsureInitialized(); + + var index = _data.Clients.FindIndex(x => x.Id == clientId); + if (index < 0) + return Task.CompletedTask; + + _data.Clients[index] = _data.Clients[index] with { LastUsedAt = DateTimeOffset.UtcNow }; + } + + return SaveAsync(cancellationToken); + } + + /// + public async Task CreateClientAsync( + string displayName, PeerEvidence evidence, IReadOnlyList scopes, CancellationToken cancellationToken = default) + { + var token = PublicVaultId.ToBase64Url(RandomNumberGenerator.GetBytes(32)); + var client = new PairedClient( + Id: Guid.NewGuid().ToString("N"), + DisplayName: displayName, + TokenHash: ComputeTokenHash(token), + WasIdentityVerified: evidence.IsVerified, + ExecutablePath: evidence.ExecutablePath, + Signer: evidence.Signer, + Scopes: scopes, + CreatedAt: DateTimeOffset.UtcNow, + LastUsedAt: null); + + lock (_lock) + { + EnsureInitialized(); + _data.Clients.Add(client); + } + + await SaveAsync(cancellationToken); + return token; + } + + /// + public Task RevokeClientAsync(string clientId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + EnsureInitialized(); + if (_data.Clients.RemoveAll(x => x.Id == clientId) == 0) + return Task.CompletedTask; + } + + return SaveAsync(cancellationToken); + } + + /// + public Task RevokeAllAsync(CancellationToken cancellationToken = default) + { + lock (_lock) + { + EnsureInitialized(); + if (_data.Clients.Count == 0 && _data.Denied.Count == 0) + return Task.CompletedTask; + + _data.Clients.Clear(); + _data.Denied.Clear(); + } + + return SaveAsync(cancellationToken); + } + + /// + public Task RecordDenialAsync(string fingerprint, CancellationToken cancellationToken = default) + { + lock (_lock) + { + EnsureInitialized(); + _data.Denied.RemoveAll(x => x.Fingerprint == fingerprint); + _data.Denied.Add(new DeniedClient(fingerprint, DateTimeOffset.UtcNow)); + } + + return SaveAsync(cancellationToken); + } + + /// + public async Task InitAsync(CancellationToken cancellationToken = default) + { + await _fileLock.WaitAsync(cancellationToken); + try + { + if (_initialized) + return; + + _file ??= await _settingsFolder.CreateFileAsync(_fileName, false, cancellationToken); + + var loaded = await TryReadAsync(_file, cancellationToken); + if (loaded is not null && !string.IsNullOrEmpty(loaded.VaultIdKey)) + { + lock (_lock) + _data = loaded; + } + else + { + // A missing or corrupt store is replaced which revokes every pairing + PairingStoreDataModel fresh; + lock (_lock) + fresh = _data = new PairingStoreDataModel { VaultIdKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) }; + + await WriteAsync(_file, fresh, cancellationToken); + } + + _initialized = true; + } + finally + { + _fileLock.Release(); + } + } + + /// + public async Task SaveAsync(CancellationToken cancellationToken = default) + { + await _fileLock.WaitAsync(cancellationToken); + try + { + _file ??= await _settingsFolder.CreateFileAsync(_fileName, false, cancellationToken); + + PairingStoreDataModel snapshot; + lock (_lock) + snapshot = _data.Clone(); + + await WriteAsync(_file, snapshot, cancellationToken); + } + finally + { + _fileLock.Release(); + } + } + + private async Task TryReadAsync(IFile file, CancellationToken cancellationToken) + { + try + { + await using var stream = await file.OpenReadAsync(cancellationToken); + return await _serializer.DeserializeAsync(stream, cancellationToken); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + return null; + } + } + + private async Task WriteAsync(IFile file, PairingStoreDataModel data, CancellationToken cancellationToken) + { + await using var destination = await file.OpenWriteAsync(cancellationToken); + await using var serialized = await _serializer.SerializeAsync(data, cancellationToken); + + destination.SetLength(0L); + serialized.Position = 0L; + await serialized.CopyToAsync(destination, cancellationToken); + await destination.FlushAsync(cancellationToken); + } + + private static string ComputeTokenHash(string token) + => Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + + private static bool FixedTimeEqualsBase64(string left, string right) + { + try + { + return CryptographicOperations.FixedTimeEquals( + Convert.FromBase64String(left), + Convert.FromBase64String(right)); + } + catch (FormatException) + { + return false; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EnsureInitialized() + { + if (!_initialized) + throw new InvalidOperationException($"{nameof(PairingStore)} must be initialized before use."); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Transport/UnixSocketApiTransport.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/UnixSocketApiTransport.cs new file mode 100644 index 000000000..a21803b11 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/UnixSocketApiTransport.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Api.Enums; + +namespace SecureFolderFS.Sdk.Api.Transport +{ + /// + /// Serves the API over a Unix domain socket restricted to the current user. + /// + /// + /// The socket file is owner-only, but the guarantee that matters is the containing directory being + /// owner-only, which is verified before the socket is bound. + /// + [UnsupportedOSPlatform("windows")] + public sealed class UnixSocketApiTransport : IApiTransport + { + private const int LISTEN_BACKLOG = 16; + + private const UnixFileMode OwnerOnlyDirectory = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + private const UnixFileMode OwnerOnlyFile = + UnixFileMode.UserRead | UnixFileMode.UserWrite; + + private const UnixFileMode GroupOrOtherAccess = + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + private readonly string _socketPath; + private Socket? _listener; + private bool _disposed; + + /// + public ApiTransportKind Kind => ApiTransportKind.UnixSocket; + + /// + public string Address => _socketPath; + + public UnixSocketApiTransport(string socketPath) + { + _socketPath = socketPath; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + EnsureOwnerOnlyDirectory(Path.GetDirectoryName(_socketPath)!); + await ClearStaleSocketAsync(cancellationToken); + + var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + listener.Bind(new UnixDomainSocketEndPoint(_socketPath)); + File.SetUnixFileMode(_socketPath, OwnerOnlyFile); + listener.Listen(LISTEN_BACKLOG); + } + catch + { + listener.Dispose(); + throw; + } + + _listener = listener; + } + + /// + public async Task AcceptAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_listener is null) + throw new InvalidOperationException($"{nameof(StartAsync)} must be called before accepting connections."); + + var accepted = await _listener.AcceptAsync(cancellationToken); + return new UnixSocketConnection(accepted); + } + + /// + /// Creates the containing directory as owner-only, or verifies an existing one is safe to use. + /// + private static void EnsureOwnerOnlyDirectory(string directoryPath) + { + if (!Directory.Exists(directoryPath)) + { + _ = Directory.CreateDirectory(directoryPath, OwnerOnlyDirectory); + return; + } + + var info = new DirectoryInfo(directoryPath); + + // A symlink here could redirect the socket somewhere world-writable + if (info.LinkTarget is not null) + throw new IOException($"Refusing to use '{directoryPath}' for the API socket because it is a symbolic link."); + + // A directory another user can write to would let them replace or observe the socket + var mode = File.GetUnixFileMode(directoryPath); + if ((mode & GroupOrOtherAccess) != 0) + throw new IOException($"Refusing to use '{directoryPath}' for the API socket because it is accessible to users other than the owner (mode: {mode})."); + } + + /// + /// Removes a socket file left behind by a previous run, distinguishing a live instance from a + /// stale file so a second instance fails instead of hijacking the endpoint. + /// + private async Task ClearStaleSocketAsync(CancellationToken cancellationToken) + { + if (!File.Exists(_socketPath)) + return; + + using var probe = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await probe.ConnectAsync(new UnixDomainSocketEndPoint(_socketPath), cancellationToken); + } + catch (SocketException) + { + // Nothing is listening, so the file is a leftover and is safe to remove. + File.Delete(_socketPath); + return; + } + + throw new IOException($"Another SecureFolderFS instance is already serving the API on '{_socketPath}'."); + } + + /// + public ValueTask DisposeAsync() + { + if (_disposed) + return ValueTask.CompletedTask; + + _disposed = true; + _listener?.Dispose(); + _listener = null; + + try + { + if (File.Exists(_socketPath)) + File.Delete(_socketPath); + } + catch (Exception) + { + // A leftover socket file is recovered from on next start by ClearStaleSocketAsync + } + + return ValueTask.CompletedTask; + } + + private sealed class UnixSocketConnection : IApiConnection + { + private readonly Socket _socket; + private readonly NetworkStream _stream; + + /// + public Stream Stream => _stream; + + /// + public ApiPeerHandle Peer { get; } + + public UnixSocketConnection(Socket socket) + { + _socket = socket; + _stream = new NetworkStream(socket, ownsSocket: false); + Peer = new ApiPeerHandle(socket); + } + + /// + public async ValueTask DisposeAsync() + { + await _stream.DisposeAsync(); + _socket.Dispose(); + } + } + } +} From df9f4a2fc9135206edcc2e34f38905745690d2a3 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:01:37 +0200 Subject: [PATCH 8/9] Moved things around --- .../Strings/en-US/Resources.resx | 57 +++++ .../PeerEvidenceProvider.cs | 155 +++++++++++++ .../Settings/PreferencesSettingsPage.xaml | 82 +++++++ .../ApiVaultChangedEventArgs.cs | 2 +- .../Helpers/ApiDiscoveryHelpers.cs | 137 +++++++++++ .../ApiRateLimiterHelper.cs} | 44 +--- ...blicVaultId.cs => PublicVaultIdHelpers.cs} | 2 +- .../Helpers/RequestCoalescerHelper.cs | 44 ++++ .../Models/ApiEndpointInfo.cs | 20 ++ .../ApiMessageModels.cs} | 0 .../{Protocol => Models}/ApiModels.cs | 2 +- .../Serialization/NdjsonChannel.cs | 184 +++++++++++++++ .../Services/ApiHost.cs | 216 ++++++++++++++++++ .../Services/IVaultApiBridge.cs | 2 +- .../Services/PairingStore.cs | 2 +- 15 files changed, 903 insertions(+), 46 deletions(-) create mode 100644 src/Platforms/SecureFolderFS.Uno/ServiceImplementation/PeerEvidenceProvider.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Helpers/ApiDiscoveryHelpers.cs rename src/Sdk/SecureFolderFS.Sdk.Api/{Policy/ApiPolicy.cs => Helpers/ApiRateLimiterHelper.cs} (69%) rename src/Sdk/SecureFolderFS.Sdk.Api/Helpers/{PublicVaultId.cs => PublicVaultIdHelpers.cs} (96%) create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Helpers/RequestCoalescerHelper.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiEndpointInfo.cs rename src/Sdk/SecureFolderFS.Sdk.Api/{Protocol/ApiMessage.cs => Models/ApiMessageModels.cs} (100%) rename src/Sdk/SecureFolderFS.Sdk.Api/{Protocol => Models}/ApiModels.cs (99%) create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Serialization/NdjsonChannel.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/ApiHost.cs diff --git a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx index 95a1fac10..39ce22a7f 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx @@ -2207,4 +2207,61 @@ Set up new credentials + + Allow app to connect? + + + {0} wants to connect to SecureFolderFS. + + + Verified publisher: {0} + + + This application's identity could not be verified + + + It will be able to: + + + See your vaults and whether they are unlocked + + + Ask you to unlock vaults, and lock them + + + Bring the SecureFolderFS window to the front + + + It can never read your files or your passwords, and unlocking always requires you to enter your credentials in SecureFolderFS. + + + Allow + + + Deny + + + App integrations + + + Let other apps on this device see your vaults and ask to unlock them + + + Connected apps + + + No apps have been allowed to connect + + + Revoke + + + Revoke all + + + Identity not verified + + + Never used + diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/PeerEvidenceProvider.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/PeerEvidenceProvider.cs new file mode 100644 index 000000000..8a002d894 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/PeerEvidenceProvider.cs @@ -0,0 +1,155 @@ +using System; +#if WINDOWS +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Win32.SafeHandles; +using SecureFolderFS.Sdk.Api.Enums; +#elif __UNO_SKIA_MACOS__ +using System.Text; +#else +using System.IO; +#endif +using SecureFolderFS.Sdk.Api.Transport; +using SecureFolderFS.Sdk.Api.Models; +using SecureFolderFS.Sdk.Api.Services; +using SecureFolderFS.Uno.PInvoke; + +namespace SecureFolderFS.Uno.ServiceImplementation +{ + /// + /// Gathers what each operating system can tell us about a connecting process. Windows can verify a + /// publisher signature; macOS and Linux cannot, and say so. Used only to inform the consent prompt and + /// never to authorize a request, as every identifier here is spoofable or race-prone. + /// + public sealed class PeerEvidenceProvider : IPeerEvidenceProvider + { +#if __UNO_SKIA_MACOS__ + private const int PROC_PID_PATH_MAX = 4096; + + // macOS: SOL_LOCAL / LOCAL_PEERPID, returning a single pid + private const int SOL_LOCAL_MACOS = 0; + private const int LOCAL_PEERPID_MACOS = 2; +#elif !WINDOWS + // Linux: SOL_SOCKET / SO_PEERCRED, returning struct ucred { pid, uid, gid } + private const int SOL_SOCKET_LINUX = 1; + private const int SO_PEERCRED_LINUX = 17; +#endif + + /// + public PeerEvidence Describe(ApiPeerHandle peer) + { + var processId = TryGetProcessId(peer); + if (processId is null) + return PeerEvidence.Unknown; + + var executablePath = TryGetExecutablePath(processId.Value); + +#if WINDOWS + // Only Windows can attest to who published the executable + if (executablePath is not null) + { + var signer = TryGetAuthenticodeSigner(executablePath); + if (signer is not null) + return new PeerEvidence(IsVerified: true, executablePath, signer); + } +#endif + + // Elsewhere the consent prompt states plainly that the identity could not be verified + return new PeerEvidence(IsVerified: false, executablePath); + } + + private static int? TryGetProcessId(ApiPeerHandle peer) + { + try + { +#if WINDOWS + if (peer.Kind == ApiTransportKind.NamedPipe && peer.PipeHandle is { } pipeHandle) + return TryGetPipeClientProcessId(pipeHandle); +#else + if (peer.Socket is { } socket) + return TryGetSocketPeerProcessId((int)socket.Handle); +#endif + } + catch (Exception) + { + // Evidence is advisory. Failing to collect it must never refuse a connection + } + + return null; + } + +#if WINDOWS + [SupportedOSPlatform("windows")] + private static int? TryGetPipeClientProcessId(SafePipeHandle pipeHandle) + => UnsafeNative.GetNamedPipeClientProcessId(pipeHandle, out var clientProcessId) ? (int)clientProcessId : null; +#else + private static int? TryGetSocketPeerProcessId(int fileDescriptor) + { +#if __UNO_SKIA_MACOS__ + var buffer = new byte[4]; + var length = buffer.Length; + if (UnsafeNative.getsockopt(fileDescriptor, SOL_LOCAL_MACOS, LOCAL_PEERPID_MACOS, buffer, ref length) != 0) + return null; + + return BitConverter.ToInt32(buffer, 0); +#else + var credentials = new byte[12]; + var length = credentials.Length; + if (UnsafeNative.getsockopt(fileDescriptor, SOL_SOCKET_LINUX, SO_PEERCRED_LINUX, credentials, ref length) != 0) + return null; + + return BitConverter.ToInt32(credentials, 0); +#endif + } +#endif + + private static string? TryGetExecutablePath(int processId) + { + try + { +#if WINDOWS + using var process = Process.GetProcessById(processId); + return process.MainModule?.FileName; +#elif __UNO_SKIA_MACOS__ + var buffer = new byte[PROC_PID_PATH_MAX]; + var length = UnsafeNative.proc_pidpath(processId, buffer, (uint)buffer.Length); + + return length > 0 ? Encoding.UTF8.GetString(buffer, 0, length) : null; +#else + return File.ResolveLinkTarget($"/proc/{processId}/exe", returnFinalTarget: true)?.FullName; +#endif + } + catch (Exception) + { + // The process may have exited, or be inaccessible to us + } + + return null; + } + +#if WINDOWS + [SupportedOSPlatform("windows")] + private static string? TryGetAuthenticodeSigner(string executablePath) + { + try + { + // X509CertificateLoader has no path that extracts an Authenticode signer from a PE, so the + // signer certificate is read from the signed file and then parsed through the loader. +#pragma warning disable SYSLIB0057 // CreateFromSignedFile is the only BCL Authenticode reader + using var signerCertificate = X509Certificate.CreateFromSignedFile(executablePath); +#pragma warning restore SYSLIB0057 + using var certificate = X509CertificateLoader.LoadCertificate(signerCertificate.GetRawCertData()); + + var commonName = certificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false); + return string.IsNullOrWhiteSpace(commonName) ? null : commonName; + } + catch (Exception) + { + // Unsigned, or the signature could not be read. Either way the caller is unverified. + return null; + } + } +#endif + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml index 45965ab5c..ad604cf8f 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml @@ -9,6 +9,7 @@ xmlns:uc="using:SecureFolderFS.Uno.UserControls" xmlns:ucab="using:SecureFolderFS.Uno.UserControls.ActionBlocks" xmlns:vm="using:SecureFolderFS.Sdk.ViewModels.Controls.Components" + xmlns:vm2="using:SecureFolderFS.Sdk.ViewModels.Controls" mc:Ignorable="d"> @@ -143,6 +144,87 @@ IsOn="{x:Bind ViewModel.OpenFolderOnUnlock, Mode=TwoWay}" /> + + + + + + + + + + + + + + + + + + + + + - public static class PublicVaultId + public static class PublicVaultIdHelpers { // 16 digest bytes equating to a 128-bit identifier private const int ID_BYTE_LENGTH = 16; diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/RequestCoalescerHelper.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/RequestCoalescerHelper.cs new file mode 100644 index 000000000..44c4661f0 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/RequestCoalescerHelper.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace SecureFolderFS.Sdk.Api.Helpers +{ + /// + /// Collapses duplicate in-flight requests so identical work is never started twice. While a request + /// for a given key is outstanding, further requests for the same key are refused entry. + /// + public sealed class RequestCoalescerHelper + { + private readonly Lock _lock = new(); + private readonly HashSet _inFlight = []; + + /// + /// Attempts to claim exclusive ownership of a unit of work. + /// + /// A scope that releases the claim when disposed, or when the + /// same work is already in flight. + public IDisposable? TryBeginScope(string key) + { + lock (_lock) + { + if (!_inFlight.Add(key)) + return null; + } + + return new Scope(this, key); + } + + private void End(string key) + { + lock (_lock) + _inFlight.Remove(key); + } + + private sealed class Scope(RequestCoalescerHelper owner, string key) : IDisposable + { + /// + public void Dispose() => owner.End(key); + } + } +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiEndpointInfo.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiEndpointInfo.cs new file mode 100644 index 000000000..a8115dd90 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiEndpointInfo.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace SecureFolderFS.Sdk.Api.Models +{ + /// + /// Describes where the API can be reached, and whether it is available at all. + /// + public sealed record ApiEndpointInfo( + [property: JsonPropertyName("schemaVersion")] int SchemaVersion, + [property: JsonPropertyName("transport")] string Transport, + [property: JsonPropertyName("address")] string Address, + [property: JsonPropertyName("enabled")] bool Enabled, + [property: JsonPropertyName("protocolMin")] int ProtocolMin, + [property: JsonPropertyName("protocolMax")] int ProtocolMax, + [property: JsonPropertyName("capabilities")] IReadOnlyList Capabilities, + [property: JsonPropertyName("appVersion")] string? AppVersion = null, + [property: JsonPropertyName("executablePath")] string? ExecutablePath = null, + [property: JsonPropertyName("processId")] int? ProcessId = null); +} \ No newline at end of file diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiMessageModels.cs similarity index 100% rename from src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs rename to src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiMessageModels.cs diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiModels.cs similarity index 99% rename from src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs rename to src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiModels.cs index 67e9fd808..44816f3ad 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiModels.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace SecureFolderFS.Sdk.Api.Protocol +namespace SecureFolderFS.Sdk.Api.Models { /// /// The first message a client sends after connecting. diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/NdjsonChannel.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/NdjsonChannel.cs new file mode 100644 index 000000000..fb7c1f4de --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/NdjsonChannel.cs @@ -0,0 +1,184 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Api.Protocol; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; + +namespace SecureFolderFS.Sdk.Api.Serialization +{ + /// + /// Reads and writes instances as newline-delimited JSON over a stream. + /// + /// + /// One compact JSON object per line, terminated by \n. Each message is serialized through the + /// shared ; this type only owns the framing. Reads are + /// bounded by so an attacker cannot force unbounded buffering. + /// + public sealed class NdjsonChannel : IDisposable + { + private const byte NEWLINE = (byte)'\n'; + private const byte CARRIAGE_RETURN = (byte)'\r'; + private const int INITIAL_BUFFER_SIZE = 4096; + + private static readonly byte[] NewlineBuffer = [NEWLINE]; + + private readonly Stream _stream; + private readonly IAsyncSerializer _serializer; + private readonly int _maxMessageBytes; + private readonly SemaphoreSlim _writeLock; + private byte[] _buffer; + private int _bufferedLength; + private bool _disposed; + + public NdjsonChannel( + Stream stream, + IAsyncSerializer? serializer = null, + int maxMessageBytes = Constants.Limits.MAX_MESSAGE_BYTES) + { + _stream = stream; + _serializer = serializer ?? ApiSerializer.Instance; + _maxMessageBytes = maxMessageBytes; + _writeLock = new SemaphoreSlim(1, 1); + _buffer = new byte[INITIAL_BUFFER_SIZE]; + } + + /// + /// Reads the next message, or when the peer closed the connection. + /// + public async Task ReadAsync(CancellationToken cancellationToken = default) + { + while (true) + { + var newlineIndex = Array.IndexOf(_buffer, NEWLINE, 0, _bufferedLength); + if (newlineIndex >= 0) + { + var consumed = newlineIndex + 1; + var lineLength = newlineIndex; + + // Tolerate CRLF from clients written against line-oriented APIs + if (lineLength > 0 && _buffer[lineLength - 1] == CARRIAGE_RETURN) + lineLength--; + + // Blank lines are permitted as keep-alives + if (lineLength == 0) + { + Consume(consumed); + continue; + } + + try + { + return await DeserializeAsync(lineLength, cancellationToken); + } + finally + { + // Discard the line even when parsing throws, otherwise one bad line loops forever. + Consume(consumed); + } + } + + // No delimiter yet, and the peer has already spent the whole budget on one line + if (_bufferedLength >= _maxMessageBytes) + { + throw new ApiProtocolException( + Constants.ErrorCodes.INVALID_REQUEST, + $"Message exceeded the {_maxMessageBytes} byte limit.", + isFatal: true); + } + + if (_bufferedLength == _buffer.Length) + Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, _maxMessageBytes + 1)); + + var read = await _stream.ReadAsync(_buffer.AsMemory(_bufferedLength), cancellationToken); + if (read == 0) + return null; + + _bufferedLength += read; + } + } + + /// + /// Writes a message to the peer. Serialization happens under the write lock so concurrent + /// notifications and responses can never interleave bytes. + /// + public async Task WriteAsync(ApiMessage message, CancellationToken cancellationToken = default) + { + await _writeLock.WaitAsync(cancellationToken); + try + { + await using var payload = await _serializer.SerializeAsync(message, cancellationToken); + + await payload.CopyToAsync(_stream, cancellationToken); + await _stream.WriteAsync(NewlineBuffer, cancellationToken); + await _stream.FlushAsync(cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + private async Task DeserializeAsync(int lineLength, CancellationToken cancellationToken) + { + try + { + using var lineStream = new MemoryStream(_buffer, 0, lineLength, writable: false); + + return await _serializer.DeserializeAsync(lineStream, cancellationToken) + ?? throw new ApiProtocolException( + Constants.ErrorCodes.INVALID_REQUEST, "Message was not a JSON object.", isFatal: false); + } + catch (JsonException ex) + { + throw new ApiProtocolException( + Constants.ErrorCodes.PARSE_ERROR, $"Message was not valid JSON: {ex.Message}", isFatal: false); + } + } + + private void Consume(int count) + { + var remaining = _bufferedLength - count; + if (remaining > 0) + Buffer.BlockCopy(_buffer, count, _buffer, 0, remaining); + + _bufferedLength = remaining; + } + + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _writeLock.Dispose(); + } + } + + /// + /// A violation of the wire protocol by the remote peer. + /// + public sealed class ApiProtocolException : Exception + { + /// + /// Gets the error code from to report to the peer. + /// + public string Code { get; } + + /// + /// Gets whether the connection must be torn down. A malformed message is recoverable; an + /// oversized one is not, because the framing is no longer trustworthy. + /// + public bool IsFatal { get; } + + public ApiProtocolException(string code, string message, bool isFatal) + : base(message) + { + Code = code; + IsFatal = isFatal; + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/ApiHost.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/ApiHost.cs new file mode 100644 index 000000000..91489f215 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/ApiHost.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Api.Helpers; +using SecureFolderFS.Sdk.Api.Models; +using SecureFolderFS.Sdk.Api.Session; +using SecureFolderFS.Sdk.Api.Transport; +using SecureFolderFS.Shared.Helpers; +using ApiConstants = SecureFolderFS.Sdk.Api.Constants; + +namespace SecureFolderFS.Sdk.Api.Services +{ + /// + public sealed class ApiHost : IApiHost + { + private readonly VaultNotificationHub _hub; + private readonly ApiSessionContext _context; + private readonly List _sessions = []; + private readonly Lock _lock = new(); + + private CancellationTokenSource? _lifetimeCts; + private IApiTransport? _transport; + private Task? _acceptLoop; + private bool _disposed; + + public ApiHost( + IVaultApiBridge bridge, + IPairingStore pairingStore, + IApiConsentService consentService, + IPeerEvidenceProvider evidenceProvider, + string appVersion) + { + _hub = new VaultNotificationHub(bridge); + _context = new ApiSessionContext( + bridge, + _hub, + pairingStore, + new ApiRateLimiterHelper(), + new RequestCoalescerHelper(), + consentService, + evidenceProvider, + appVersion); + } + + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_transport is not null) + return; + + var transport = CreateTransport(); + await transport.StartAsync(cancellationToken); + + _transport = transport; + _lifetimeCts = new CancellationTokenSource(); + + // Written only after the listener is bound, so a client woken by the file change finds something accepting on the other end + PublishEndpoint(enabled: true); + + _acceptLoop = Task.Run(() => AcceptLoopAsync(transport, _lifetimeCts.Token), CancellationToken.None); + } + + /// + public async Task StopAsync(CancellationToken cancellationToken = default) + { + var transport = _transport; + if (transport is null) + return; + + _transport = null; + if (_lifetimeCts is not null) + await _lifetimeCts.CancelAsync(); + + await transport.DisposeAsync(); + if (_acceptLoop is not null) + await Task.WhenAny(_acceptLoop, Task.Delay(TimeSpan.FromSeconds(2), cancellationToken)); + + ApiSession[] sessions; + lock (_lock) + { + sessions = _sessions.ToArray(); + _sessions.Clear(); + } + + foreach (var session in sessions) + await SafetyHelpers.NoFailureAsync(async () => await session.DisposeAsync()); + + _lifetimeCts?.Dispose(); + _lifetimeCts = null; + _acceptLoop = null; + } + + /// + public void PublishDisabled() => PublishEndpoint(enabled: false); + + private void PublishEndpoint(bool enabled) + { + try + { + var (kind, address) = ApiDiscoveryHelpers.ResolveEndpoint(); + ApiDiscoveryHelpers.Write(new ApiEndpointInfo( + ApiConstants.PROTOCOL_VERSION, + ApiDiscoveryHelpers.GetTransportName(kind), + address, + enabled, + ApiConstants.PROTOCOL_VERSION_MIN, + ApiConstants.PROTOCOL_VERSION, + Constants.Scopes.All, + _context.AppVersion, + SafetyHelpers.NoFailureResult(() => Environment.ProcessPath), + Environment.ProcessId)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The API still works for a client that already knows the address, so this is not fatal + } + } + + private async Task AcceptLoopAsync(IApiTransport transport, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + IApiConnection connection; + try + { + connection = await transport.AcceptAsync(cancellationToken); + } + catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException) + { + break; + } + catch (Exception) + { + continue; + } + + if (!TryAdmit()) + { + // Refused connections are closed immediately, so a client sees a clean disconnect + await SafetyHelpers.NoFailureAsync(async () => await connection.DisposeAsync()); + continue; + } + + var session = new ApiSession(connection, _context); + lock (_lock) + _sessions.Add(session); + + _ = Task.Run(() => RunSessionAsync(session, cancellationToken), CancellationToken.None); + } + } + + /// + /// Decides whether a new connection fits within the configured budgets. + /// + private bool TryAdmit() + { + lock (_lock) + { + if (_sessions.Count >= Constants.Limits.MAX_CONNECTIONS) + return false; + + // Unauthenticated sockets get their own small allowance so an unpaired caller cannot lock out paired clients. + return _sessions.Count(x => !x.IsAuthenticated) < Constants.Limits.MAX_UNAUTHENTICATED_CONNECTIONS; + } + } + + private async Task RunSessionAsync(ApiSession session, CancellationToken cancellationToken) + { + try + { + await session.RunAsync(cancellationToken); + } + catch (Exception ex) + { + _ = ex; + } + finally + { + lock (_lock) + _sessions.Remove(session); + + await SafetyHelpers.NoFailureAsync(async () => await session.DisposeAsync()); + } + } + + private static IApiTransport CreateTransport() + { + var (_, address) = ApiDiscoveryHelpers.ResolveEndpoint(); + if (!OperatingSystem.IsWindows()) + return new UnixSocketApiTransport(address); + + // The endpoint file advertises the full \\.\pipe\ form; the server API takes the bare name. + var pipeName = address.StartsWith(@"\\.\pipe\", StringComparison.OrdinalIgnoreCase) + ? address[@"\\.\pipe\".Length..] + : address; + + return new NamedPipeApiTransport(pipeName); + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + await StopAsync(); + _hub.Dispose(); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs index 90bebe4ff..7868c1c89 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using SecureFolderFS.Sdk.Api.Protocol; using SecureFolderFS.Sdk.Api.Enums; using SecureFolderFS.Sdk.Api.EventArguments; +using SecureFolderFS.Sdk.Api.Models; namespace SecureFolderFS.Sdk.Api.Services { diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs index 69a8fabc0..3de71ed57 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs @@ -112,7 +112,7 @@ public Task TouchLastUsedAsync(string clientId, CancellationToken cancellationTo public async Task CreateClientAsync( string displayName, PeerEvidence evidence, IReadOnlyList scopes, CancellationToken cancellationToken = default) { - var token = PublicVaultId.ToBase64Url(RandomNumberGenerator.GetBytes(32)); + var token = PublicVaultIdHelpers.ToBase64Url(RandomNumberGenerator.GetBytes(32)); var client = new PairedClient( Id: Guid.NewGuid().ToString("N"), DisplayName: displayName, From 69ba83e2f56f112e1a70dff4d1abd3dbe6e70fae Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:46:41 +0200 Subject: [PATCH 9/9] Added UnoVaultApiBridge --- .../UnoVaultApiBridge.cs | 323 +++++++++++ .../Helpers/PublicVaultIdHelpers.cs | 6 +- .../Serialization/ApiSerializer.cs | 33 ++ .../Services/VaultNotificationHub.cs | 129 +++++ .../Session/ApiSession.cs | 501 ++++++++++++++++++ 5 files changed, 989 insertions(+), 3 deletions(-) create mode 100644 src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoVaultApiBridge.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Serialization/ApiSerializer.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/VaultNotificationHub.cs create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Session/ApiSession.cs diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoVaultApiBridge.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoVaultApiBridge.cs new file mode 100644 index 000000000..4ccd0d8d5 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoVaultApiBridge.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; +using SecureFolderFS.Sdk.Api.Enums; +using SecureFolderFS.Sdk.Api.EventArguments; +using SecureFolderFS.Sdk.Api.Helpers; +using SecureFolderFS.Sdk.Api.Models; +using SecureFolderFS.Sdk.Api.Services; +using SecureFolderFS.Sdk.Messages; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels; +using SecureFolderFS.Sdk.ViewModels.Controls.VaultList; +using SecureFolderFS.Sdk.ViewModels.Views.Root; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Extensions; +using ApiConstants = SecureFolderFS.Sdk.Api.Constants; + +namespace SecureFolderFS.Uno.ServiceImplementation +{ + /// + /// Exposes live vault state to the local integration API. + /// + public sealed class UnoVaultApiBridge : IVaultApiBridge, IRecipient, IRecipient, IDisposable + { + private readonly IPairingStore _pairingStore; + private MainViewModel? _mainViewModel; + private SynchronizationContext? _synchronizationContext; + private VaultInfo[] _snapshot = []; + private bool _disposed; + + /// + public bool IsAvailable => _mainViewModel is not null; + + /// + public event EventHandler? VaultChanged; + + /// + /// Gets or sets the callback that shows the unlock prompt for a vault. + /// + public Func>? ShowUnlockPromptAsync { get; set; } + + /// + /// Gets or sets the callback that brings the main window to the foreground. + /// + public Func? ShowMainWindow { get; set; } + + public UnoVaultApiBridge(IPairingStore pairingStore) + { + _pairingStore = pairingStore; + } + + /// + /// Connects the bridge to live application state, once the main view model exists. + /// + public void Attach(MainViewModel mainViewModel, SynchronizationContext? synchronizationContext) + { + _mainViewModel = mainViewModel; + _synchronizationContext = synchronizationContext; + + mainViewModel.VaultListViewModel.Items.CollectionChanged += Items_CollectionChanged; + foreach (var item in mainViewModel.VaultListViewModel.Items) + item.VaultViewModel.PropertyChanged += VaultViewModel_PropertyChanged; + + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + + Rebuild(); + } + + /// + public IReadOnlyList GetVaults() => Volatile.Read(ref _snapshot); + + /// + public void Receive(VaultUnlockedMessage message) => Rebuild(); + + /// + public void Receive(VaultLockedMessage message) => Rebuild(); + + /// + /// Recomputes the snapshot and raises an event for every difference. + /// + private void Rebuild() + { + if (_mainViewModel is null) + return; + + var previous = Volatile.Read(ref _snapshot); + var current = BuildSnapshot(_mainViewModel); + Volatile.Write(ref _snapshot, current); + + if (VaultChanged is null) + return; + + var previousById = previous.ToDictionary(x => x.Id); + var currentById = current.ToDictionary(x => x.Id); + + foreach (var vault in current) + { + if (!previousById.TryGetValue(vault.Id, out var before)) + { + NotifyVaultChanged(ApiVaultChangeKind.Added, vault.Id, vault); + continue; + } + + if (before.State != vault.State) + { + var kind = vault.State == ApiConstants.VaultStates.UNLOCKED + ? ApiVaultChangeKind.Unlocked + : ApiVaultChangeKind.Locked; + + NotifyVaultChanged(kind, vault.Id, vault); + } + else if (!string.Equals(before.Name, vault.Name, StringComparison.Ordinal)) + { + NotifyVaultChanged(ApiVaultChangeKind.Renamed, vault.Id, vault); + } + } + + foreach (var vault in previous) + { + if (!currentById.ContainsKey(vault.Id)) + NotifyVaultChanged(ApiVaultChangeKind.Removed, vault.Id, null); + } + } + + private void NotifyVaultChanged(ApiVaultChangeKind kind, string vaultId, VaultInfo? vault) + => VaultChanged?.Invoke(this, new ApiVaultChangedEventArgs(kind, vaultId, vault)); + + private VaultInfo[] BuildSnapshot(MainViewModel mainViewModel) + { + var vaultIdKey = _pairingStore.VaultIdKey; + var results = new List(); + + foreach (var item in mainViewModel.VaultListViewModel.Items) + { + var vaultViewModel = item.VaultViewModel; + var persistableId = vaultViewModel.VaultModel.DataModel.PersistableId; + if (string.IsNullOrEmpty(persistableId)) + continue; + + results.Add(new VaultInfo( + PublicVaultIdHelpers.Compute(vaultIdKey, persistableId), + vaultViewModel.Title ?? string.Empty, + vaultViewModel.IsUnlocked ? ApiConstants.VaultStates.UNLOCKED : ApiConstants.VaultStates.LOCKED, + TryGetMountPath(vaultViewModel), + vaultViewModel.LastAccessDate)); + } + + return results.ToArray(); + } + + private static string? TryGetMountPath(VaultViewModel vaultViewModel) + { + if (!vaultViewModel.IsUnlocked) + return null; + + try + { + return vaultViewModel.GetUnlockedViewModel().StorageRoot.VirtualizedRoot.Id; + } + catch (Exception) + { + return null; + } + } + + /// + public Task RequestUnlockAsync(string vaultId, CancellationToken cancellationToken = default) + { + return InvokeOnUiThreadAsync(async () => + { + if (!TryResolve(vaultId, out var vaultViewModel)) + return ApiActionOutcome.NotFound; + + if (vaultViewModel.IsUnlocked) + return ApiActionOutcome.NoChange; + + if (ShowUnlockPromptAsync is null) + return ApiActionOutcome.Unavailable; + + var shown = await ShowUnlockPromptAsync(vaultViewModel); + return shown ? ApiActionOutcome.Ok : ApiActionOutcome.AlreadyPending; + }); + } + + /// + public Task LockAsync(string vaultId, CancellationToken cancellationToken = default) + { + return InvokeOnUiThreadAsync(() => + { + if (!TryResolve(vaultId, out var vaultViewModel)) + return Task.FromResult(ApiActionOutcome.NotFound); + + if (!vaultViewModel.IsUnlocked) + return Task.FromResult(ApiActionOutcome.NoChange); + + WeakReferenceMessenger.Default.Send(new VaultLockRequestedMessage(vaultViewModel.VaultModel)); + return Task.FromResult(ApiActionOutcome.Ok); + }); + } + + /// + public Task RevealAsync(string vaultId, CancellationToken cancellationToken = default) + { + return InvokeOnUiThreadAsync(async () => + { + if (!TryResolve(vaultId, out var vaultViewModel)) + return ApiActionOutcome.NotFound; + + if (!vaultViewModel.IsUnlocked) + return ApiActionOutcome.InvalidState; + + var fileExplorerService = DI.OptionalService(); + if (fileExplorerService is null) + return ApiActionOutcome.Unavailable; + + var unlocked = vaultViewModel.GetUnlockedViewModel(); + await fileExplorerService.TryOpenInFileExplorerAsync(unlocked.StorageRoot.VirtualizedRoot, cancellationToken); + + return ApiActionOutcome.Ok; + }); + } + + /// + public Task ShowMainWindowAsync(CancellationToken cancellationToken = default) + { + return InvokeOnUiThreadAsync(async () => + { + if (ShowMainWindow is null) + return ApiActionOutcome.Unavailable; + + await ShowMainWindow(); + return ApiActionOutcome.Ok; + }); + } + + private bool TryResolve(string vaultId, out VaultViewModel vaultViewModel) + { + vaultViewModel = null!; + + if (_mainViewModel is null) + return false; + + var vaultIdKey = _pairingStore.VaultIdKey; + foreach (var item in _mainViewModel.VaultListViewModel.Items) + { + var persistableId = item.VaultViewModel.VaultModel.DataModel.PersistableId; + if (string.IsNullOrEmpty(persistableId)) + continue; + + if (!string.Equals(PublicVaultIdHelpers.Compute(vaultIdKey, persistableId), vaultId, StringComparison.Ordinal)) + continue; + + vaultViewModel = item.VaultViewModel; + return true; + } + + return false; + } + + private async Task InvokeOnUiThreadAsync(Func> action) + { + if (_mainViewModel is null) + return ApiActionOutcome.Unavailable; + + var outcome = ApiActionOutcome.Unavailable; + await _synchronizationContext.PostOrExecuteAsync(async () => + { + try + { + outcome = await action(); + } + catch (Exception) + { + outcome = ApiActionOutcome.Unavailable; + } + }); + + return outcome; + } + + private void Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + foreach (var item in e.OldItems?.OfType() ?? []) + item.VaultViewModel.PropertyChanged -= VaultViewModel_PropertyChanged; + + foreach (var item in e.NewItems?.OfType() ?? []) + item.VaultViewModel.PropertyChanged += VaultViewModel_PropertyChanged; + + Rebuild(); + } + + private void VaultViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(VaultViewModel.Title) or nameof(VaultViewModel.IsUnlocked)) + Rebuild(); + } + + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + WeakReferenceMessenger.Default.UnregisterAll(this); + + if (_mainViewModel is not null) + { + _mainViewModel.VaultListViewModel.Items.CollectionChanged -= Items_CollectionChanged; + foreach (var item in _mainViewModel.VaultListViewModel.Items) + item.VaultViewModel.PropertyChanged -= VaultViewModel_PropertyChanged; + } + + _mainViewModel = null; + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs index c57329f5b..031bf97d3 100644 --- a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs @@ -17,13 +17,13 @@ public static class PublicVaultIdHelpers /// /// Computes the public identifier for a vault. /// - /// The per-installation secret held by the pairing store. + /// The per-installation vault ID key held by the pairing store. /// The application's internal identifier for the vault. [SkipLocalsInit] - public static string Compute(ReadOnlySpan installSecret, string persistableId) + public static string Compute(ReadOnlySpan vaultIdKey, string persistableId) { Span digest = stackalloc byte[32]; - HMACSHA256.HashData(installSecret, Encoding.UTF8.GetBytes(persistableId), digest); + HMACSHA256.HashData(vaultIdKey, Encoding.UTF8.GetBytes(persistableId), digest); return ToBase64Url(digest[..ID_BYTE_LENGTH]); } diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/ApiSerializer.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/ApiSerializer.cs new file mode 100644 index 000000000..829e557a1 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/ApiSerializer.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Sdk.Api.Serialization +{ + /// + /// The single JSON configuration used for every API message and persisted file. Writing is + /// non-indented so a payload never breaks the one-message-per-line wire framing, and null members are omitted. + /// + public sealed class ApiSerializer : StreamSerializer + { + /// + /// Gets the shared API serializer instance. + /// + public new static ApiSerializer Instance { get; } = new(); + + /// + /// Gets the underlying options, for the synchronous file paths that cannot await a stream. + /// + public static JsonSerializerOptions Options => Instance.SerializerOptions; + + private ApiSerializer() : base(new JsonSerializerOptions + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = false, + NumberHandling = JsonNumberHandling.Strict + }) + { + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/VaultNotificationHub.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/VaultNotificationHub.cs new file mode 100644 index 000000000..a5e3e8a58 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/VaultNotificationHub.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using SecureFolderFS.Sdk.Api.Enums; +using SecureFolderFS.Sdk.Api.EventArguments; +using SecureFolderFS.Sdk.Api.Models; +using SecureFolderFS.Sdk.Api.Protocol; + +namespace SecureFolderFS.Sdk.Api.Services +{ + /// + /// Assigns revisions to vault changes and distributes them out to subscribed sessions. + /// + /// + /// Every change increments a monotonic revision carried on snapshots and notifications, so a client + /// that sees the revision jump by more than one knows it missed an event and can resynchronize. + /// + public sealed class VaultNotificationHub : IDisposable + { + private readonly IVaultApiBridge _bridge; + private readonly Lock _lock = new(); + private readonly Dictionary> _subscribers; + private long _revision; + private bool _disposed; + + public VaultNotificationHub(IVaultApiBridge bridge) + { + _bridge = bridge; + _bridge.VaultChanged += OnVaultChanged; + _subscribers = new(); + } + + /// + /// Gets the current vault list without subscribing to further changes. + /// + public VaultListResponse GetSnapshot() + { + lock (_lock) + return new VaultListResponse(_revision, _bridge.GetVaults()); + } + + /// + /// Subscribes to changes and returns the snapshot those changes apply on top of. Registration and + /// snapshot capture happen under one lock, so no change can fall between them. + /// + /// + /// Receives notifications. Must not block. It is invoked while the hub lock is held, so it should + /// enqueue rather than perform I/O. Returning means the subscriber's queue + /// overflowed, which surfaces to the client as a revision gap. + /// + public (VaultListResponse Snapshot, IDisposable Subscription) Subscribe(Func send) + { + lock (_lock) + { + var id = Guid.NewGuid(); + _subscribers[id] = send; + + var snapshot = new VaultListResponse(_revision, _bridge.GetVaults()); + return (snapshot, new HubSubscription(this, id)); + } + } + + private void OnVaultChanged(object? sender, ApiVaultChangedEventArgs e) + { + lock (_lock) + { + if (_disposed) + return; + + var notification = CreateNotification(e, ++_revision); + if (notification is null) + return; + + // Dispatched under the lock so notifications reach every subscriber in revision order. + // Safe only because subscribers enqueue rather than write to the socket + foreach (var subscriber in _subscribers.Values) + subscriber(notification); + } + } + + private static ApiMessage? CreateNotification(ApiVaultChangedEventArgs e, long revision) + { + if (e.Kind == ApiVaultChangeKind.Removed) + return ApiMessage.Notification(Constants.Events.VAULT_REMOVED, new VaultRemovedNotification(revision, e.VaultId)); + + if (e.Vault is null) + return null; + + var method = e.Kind switch + { + ApiVaultChangeKind.Added => Constants.Events.VAULT_ADDED, + ApiVaultChangeKind.Renamed => Constants.Events.VAULT_RENAMED, + ApiVaultChangeKind.Unlocked => Constants.Events.VAULT_UNLOCKED, + ApiVaultChangeKind.Locked => Constants.Events.VAULT_LOCKED, + _ => null + }; + + return method is null + ? null + : ApiMessage.Notification(method, new VaultChangedNotification(revision, e.Vault)); + } + + private void Unsubscribe(Guid id) + { + lock (_lock) + _subscribers.Remove(id); + } + + /// + public void Dispose() + { + lock (_lock) + { + if (_disposed) + return; + + _disposed = true; + _bridge.VaultChanged -= OnVaultChanged; + _subscribers.Clear(); + } + } + + private sealed class HubSubscription(VaultNotificationHub hub, Guid id) : IDisposable + { + /// + public void Dispose() => hub.Unsubscribe(id); + } + } +} diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Session/ApiSession.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Session/ApiSession.cs new file mode 100644 index 000000000..9da434ca8 --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk.Api/Session/ApiSession.cs @@ -0,0 +1,501 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Api.Enums; +using SecureFolderFS.Sdk.Api.Helpers; +using SecureFolderFS.Sdk.Api.Models; +using SecureFolderFS.Sdk.Api.Protocol; +using SecureFolderFS.Sdk.Api.Serialization; +using SecureFolderFS.Sdk.Api.Services; +using SecureFolderFS.Sdk.Api.Transport; +using static SecureFolderFS.Sdk.Api.Constants; + +namespace SecureFolderFS.Sdk.Api.Session +{ + /// + /// Serves one connected client for the lifetime of its connection. + /// + internal sealed class ApiSession : IAsyncDisposable + { + // A client that stops reading must not grow server memory without bound. On overflow a + // notification is dropped, and the resulting revision gap tells the client to resynchronize + private const int OUTBOUND_QUEUE_CAPACITY = 256; + + private static readonly string[] DefaultRequestedScopes = + [ + Scopes.VAULTS_READ, + Scopes.VAULTS_TRIGGER, + Scopes.APP_CONTROL + ]; + + private readonly IApiConnection _connection; + private readonly ApiSessionContext _context; + private readonly NdjsonChannel _channel; + private readonly Channel _outbound; + private readonly PeerEvidence _evidence; + + private PairedClient? _client; + private IReadOnlyList _scopes = []; + private string _clientName = "Unknown application"; + private IDisposable? _subscription; + private bool _helloReceived; + private bool _disposed; + + /// Gets whether this session has presented a valid token. + public bool IsAuthenticated => _client is not null; + + public ApiSession(IApiConnection connection, ApiSessionContext context) + { + _connection = connection; + _context = context; + _channel = new NdjsonChannel(connection.Stream); + _evidence = SafeDescribePeer(connection.Peer, context.EvidenceProvider); + _outbound = Channel.CreateBounded(new BoundedChannelOptions(OUTBOUND_QUEUE_CAPACITY) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true + }); + } + + /// + /// Runs the read loop until the client disconnects or the host shuts down. + /// + public async Task RunAsync(CancellationToken cancellationToken) + { + using var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var pumpTask = PumpOutboundAsync(sessionCts.Token); + + // A connection that never authenticates is dropped, so idle unpaired sockets cannot occupy + // the small unauthenticated budget indefinitely + using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + handshakeCts.CancelAfter(TimeSpan.FromSeconds(Limits.HANDSHAKE_TIMEOUT_SECONDS)); + + try + { + while (!cancellationToken.IsCancellationRequested) + { + var readToken = IsAuthenticated ? cancellationToken : handshakeCts.Token; + + ApiMessage? request; + try + { + request = await _channel.ReadAsync(readToken); + } + catch (ApiProtocolException ex) + { + await TrySendAsync(ApiMessage.Failure(null, ex.Code, ex.Message), cancellationToken); + if (ex.IsFatal) + break; + + continue; + } + + if (request is null) + break; + + var response = await DispatchAsync(request, cancellationToken); + if (response is not null) + await TrySendAsync(response, cancellationToken); + } + } + catch (OperationCanceledException) + { + // Shutdown, or the handshake deadline elapsed. + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + // The peer vanished mid-conversation. + } + finally + { + await sessionCts.CancelAsync(); + await Task.WhenAny(pumpTask, Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None)); + } + } + + private async Task DispatchAsync(ApiMessage request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(request.Method)) + return ApiMessage.Failure(request.Id, ErrorCodes.INVALID_REQUEST, "Missing 'method'."); + + // Requests without an identifier expect no reply + if (request.Id is null) + return null; + + var id = request.Id.Value; + if (request.Method == Methods.HELLO) + return await HandleHelloAsync(id, request, cancellationToken); + + if (!_helloReceived) + return ApiMessage.Failure(id, ErrorCodes.UNAUTHORIZED, "Send 'hello' first."); + + if (request.Method == Methods.PAIR) + return await HandlePairAsync(id, request, cancellationToken); + + if (!IsAuthenticated) + return ApiMessage.Failure(id, ErrorCodes.PAIRING_REQUIRED, "This client is not paired."); + + var requiredScope = GetRequiredScope(request.Method); + if (requiredScope is null) + return ApiMessage.Failure(id, ErrorCodes.UNKNOWN_METHOD, $"Unknown method '{request.Method}'."); + + if (!_scopes.Contains(requiredScope)) + return ApiMessage.Failure(id, ErrorCodes.FORBIDDEN_SCOPE, $"Missing scope '{requiredScope}'."); + + var (isAllowed, retryAfterMs) = _context.RateLimiter.Check(GetRateLimitKey(), GetRequestClass(request.Method)); + if (!isAllowed) + return ApiMessage.Failure(id, ErrorCodes.RATE_LIMITED, "Request budget exceeded.", retryAfterMs); + + if (!_context.Bridge.IsAvailable) + return ApiMessage.Failure(id, ErrorCodes.INVALID_STATE, "The application is still starting up."); + + return request.Method switch + { + Methods.VAULTS_LIST => ApiMessage.Response(id, _context.Hub.GetSnapshot()), + Methods.VAULTS_SUBSCRIBE => HandleSubscribe(id), + Methods.VAULTS_UNSUBSCRIBE => HandleUnsubscribe(id), + Methods.VAULTS_REQUEST_UNLOCK => await HandleVaultActionAsync(id, request, VaultAction.RequestUnlock, cancellationToken), + Methods.VAULTS_LOCK => await HandleVaultActionAsync(id, request, VaultAction.Lock, cancellationToken), + Methods.VAULTS_REVEAL => await HandleVaultActionAsync(id, request, VaultAction.Reveal, cancellationToken), + Methods.APP_SHOW => await HandleShowAsync(id, cancellationToken), + _ => ApiMessage.Failure(id, ErrorCodes.UNKNOWN_METHOD, $"Unknown method '{request.Method}'.") + }; + } + + private async Task HandleHelloAsync(long id, ApiMessage request, CancellationToken cancellationToken) + { + HelloRequest? hello; + try + { + hello = request.GetParams(); + } + catch (Exception) + { + return ApiMessage.Failure(id, ErrorCodes.INVALID_PARAMS, "Malformed 'hello' parameters."); + } + + if (hello is null) + return ApiMessage.Failure(id, ErrorCodes.INVALID_PARAMS, "Missing 'hello' parameters."); + + // Ranges must overlap in both directions, otherwise neither side can be understood. + if (hello.ProtocolMax < PROTOCOL_VERSION_MIN || hello.ProtocolMin > PROTOCOL_VERSION) + { + return ApiMessage.Failure( + id, + ErrorCodes.UNSUPPORTED_VERSION, + $"This build speaks protocol {PROTOCOL_VERSION_MIN}-{PROTOCOL_VERSION}."); + } + + _helloReceived = true; + _clientName = SanitizeClientName(hello.ClientName); + + var client = _context.PairingStore.Authenticate(hello.Token); + if (client is not null) + { + _client = client; + _scopes = client.Scopes; + + try + { + await _context.PairingStore.TouchLastUsedAsync(client.Id, cancellationToken); + } + catch (Exception) + { + // A failed write must not break authentication. + } + + _clientName = client.DisplayName; + } + + var state = IsAuthenticated + ? SessionStates.PAIRED + : SessionStates.PAIRING_REQUIRED; + + return ApiMessage.Response(id, new HelloResponse( + PROTOCOL_VERSION, + _context.AppVersion, + state, + Scopes.All, + _scopes)); + } + + private async Task HandlePairAsync(long id, ApiMessage request, CancellationToken cancellationToken) + { + if (IsAuthenticated) + return ApiMessage.Response(id, new PairResponse(string.Empty, _scopes)); + + var fingerprint = ComputeFingerprint(_evidence.ExecutablePath, _clientName); + if (_context.PairingStore.IsInDenialCooldown(fingerprint)) + { + return ApiMessage.Failure( + id, + ErrorCodes.PAIRING_UNAVAILABLE, + "This application was recently refused and must wait before asking again.", + (int)_context.PairingStore.DenialCooldown.TotalMilliseconds); + } + + var (isAllowed, retryAfterMs) = _context.RateLimiter.Check(fingerprint, ApiRequestType.Pairing); + if (!isAllowed) + return ApiMessage.Failure(id, ErrorCodes.RATE_LIMITED, "Too many pairing attempts.", retryAfterMs); + + var requestedScopes = NormalizeScopes(request.GetParams()?.Scopes); + + // Only one consent prompt may be open at a time, so a burst of connections cannot stack dialogs. + using var scope = _context.Coalescer.TryBeginScope("pairing"); + if (scope is null) + { + return ApiMessage.Failure( + id, + ErrorCodes.PAIRING_UNAVAILABLE, + "Another pairing request is already awaiting a decision."); + } + + ApiConsentResult consent; + try + { + consent = await _context.ConsentService.RequestConsentAsync( + new ApiConsentRequest(_clientName, _evidence, requestedScopes), + cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + return ApiMessage.Failure(id, ErrorCodes.INTERNAL_ERROR, "The consent prompt could not be shown."); + } + + if (!consent.IsGranted || consent.GrantedScopes.Count == 0) + { + await _context.PairingStore.RecordDenialAsync(fingerprint, cancellationToken); + return ApiMessage.Failure(id, ErrorCodes.PAIRING_DENIED, "The user declined the request."); + } + + var token = await _context.PairingStore.CreateClientAsync(_clientName, _evidence, consent.GrantedScopes, cancellationToken); + + _client = _context.PairingStore.Authenticate(token); + _scopes = consent.GrantedScopes; + + return ApiMessage.Response(id, new PairResponse(token, consent.GrantedScopes)); + } + + private ApiMessage HandleSubscribe(long id) + { + _subscription?.Dispose(); + + var (snapshot, subscription) = _context.Hub.Subscribe(TryEnqueueNotification); + _subscription = subscription; + + return ApiMessage.Response(id, snapshot); + } + + private ApiMessage HandleUnsubscribe(long id) + { + _subscription?.Dispose(); + _subscription = null; + + return ApiMessage.Response(id, new ActionResponse(ActionStatus.OK)); + } + + private async Task HandleVaultActionAsync( + long id, + ApiMessage request, + VaultAction action, + CancellationToken cancellationToken) + { + VaultRequest? parameters; + try + { + parameters = request.GetParams(); + } + catch (Exception) + { + return ApiMessage.Failure(id, ErrorCodes.INVALID_PARAMS, "Malformed parameters."); + } + + if (string.IsNullOrEmpty(parameters?.VaultId)) + return ApiMessage.Failure(id, ErrorCodes.INVALID_PARAMS, "Missing 'vaultId'."); + + var vaultId = parameters.VaultId; + using var scope = _context.Coalescer.TryBeginScope($"{action}:{vaultId}"); + if (scope is null) + return ApiMessage.Response(id, new ActionResponse(ActionStatus.ALREADY_PENDING)); + + var outcome = action switch + { + VaultAction.RequestUnlock => await _context.Bridge.RequestUnlockAsync(vaultId, cancellationToken), + VaultAction.Lock => await _context.Bridge.LockAsync(vaultId, cancellationToken), + VaultAction.Reveal => await _context.Bridge.RevealAsync(vaultId, cancellationToken), + _ => ApiActionOutcome.Unavailable + }; + + return TranslateOutcome(id, outcome); + } + + private async Task HandleShowAsync(long id, CancellationToken cancellationToken) + { + var outcome = await _context.Bridge.ShowMainWindowAsync(cancellationToken); + return TranslateOutcome(id, outcome); + } + + private static ApiMessage TranslateOutcome(long id, ApiActionOutcome outcome) => outcome switch + { + ApiActionOutcome.Ok => ApiMessage.Response(id, new ActionResponse(ActionStatus.OK)), + ApiActionOutcome.AlreadyPending => ApiMessage.Response(id, new ActionResponse(ActionStatus.ALREADY_PENDING)), + ApiActionOutcome.NoChange => ApiMessage.Response(id, new ActionResponse(ActionStatus.NO_CHANGE)), + ApiActionOutcome.NotFound => ApiMessage.Failure(id, ErrorCodes.NOT_FOUND, "No such vault."), + ApiActionOutcome.InvalidState => ApiMessage.Failure(id, ErrorCodes.INVALID_STATE, "The vault is not in a state that allows this."), + _ => ApiMessage.Failure(id, ErrorCodes.INTERNAL_ERROR, "The request could not be completed.") + }; + + private bool TryEnqueueNotification(ApiMessage message) + => _outbound.Writer.TryWrite(message); + + private async Task PumpOutboundAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var message in _outbound.Reader.ReadAllAsync(cancellationToken)) + await _channel.WriteAsync(message, cancellationToken); + } + catch (OperationCanceledException) + { + // Session ending. + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + // The peer went away; the read loop will notice and tear the session down. + } + } + + private async Task TrySendAsync(ApiMessage message, CancellationToken cancellationToken) + { + try + { + await _channel.WriteAsync(message, cancellationToken); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) + { + // Losing a response to a vanished peer is not actionable. + } + } + + private string GetRateLimitKey() + { + // Keyed by pairing when known, else by the executable, so reconnecting does not reset budgets. + return _client?.Id ?? ComputeFingerprint(_evidence.ExecutablePath, _clientName); + } + + // Recognizes a caller across pairing attempts. Denial cooldowns and pre-pairing rate budgets are + // keyed by this, so simply reconnecting cannot hand a misbehaving caller a fresh start. + private static string ComputeFingerprint(string? executablePath, string clientName) + { + var material = string.IsNullOrEmpty(executablePath) ? $"name:{clientName}" : $"path:{executablePath}"; + return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(material))); + } + + private static string? GetRequiredScope(string method) => method switch + { + Methods.VAULTS_LIST or + Methods.VAULTS_SUBSCRIBE or + Methods.VAULTS_UNSUBSCRIBE => Scopes.VAULTS_READ, + + Methods.VAULTS_REQUEST_UNLOCK or + Methods.VAULTS_LOCK or + Methods.VAULTS_REVEAL => Scopes.VAULTS_TRIGGER, + + Methods.APP_SHOW => Scopes.APP_CONTROL, + + _ => null + }; + + private static ApiRequestType GetRequestClass(string method) => method switch + { + Methods.VAULTS_LIST or + Methods.VAULTS_SUBSCRIBE or + Methods.VAULTS_UNSUBSCRIBE => ApiRequestType.Read, + + _ => ApiRequestType.Trigger + }; + + private static IReadOnlyList NormalizeScopes(IReadOnlyList? requested) + { + if (requested is null || requested.Count == 0) + return DefaultRequestedScopes; + + // Silently discard unrecognized scopes so a client cannot inflate the prompt. + var filtered = requested.Where(DefaultRequestedScopes.Contains).Distinct().ToArray(); + return filtered.Length == 0 ? DefaultRequestedScopes : filtered; + } + + private static string SanitizeClientName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return "Unknown application"; + + var trimmed = name.Trim(); + if (trimmed.Length > Limits.MAX_CLIENT_NAME_LENGTH) + trimmed = trimmed[..Limits.MAX_CLIENT_NAME_LENGTH]; + + // Control characters could forge line breaks or spoof surrounding text in the consent prompt. + return new string(trimmed.Where(x => !char.IsControl(x)).ToArray()); + } + + private static PeerEvidence SafeDescribePeer(ApiPeerHandle peer, IPeerEvidenceProvider provider) + { + try + { + return provider.Describe(peer); + } + catch (Exception) + { + // Evidence is advisory, so failing to collect it must never refuse a connection. + return PeerEvidence.Unknown; + } + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + + _subscription?.Dispose(); + _subscription = null; + + _outbound.Writer.TryComplete(); + _channel.Dispose(); + + await _connection.DisposeAsync(); + } + + private enum VaultAction + { + RequestUnlock, + Lock, + Reveal + } + } + + /// + /// The services every session shares. + /// + internal sealed record ApiSessionContext( + IVaultApiBridge Bridge, + VaultNotificationHub Hub, + IPairingStore PairingStore, + ApiRateLimiterHelper RateLimiter, + RequestCoalescerHelper Coalescer, + IApiConsentService ConsentService, + IPeerEvidenceProvider EvidenceProvider, + string AppVersion); +}