diff --git a/.gitignore b/.gitignore index 5d0023a5..213f7069 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ Server/chatserver.exe Server/config.yaml Server/data/ + +# Test coverage artifacts +Server/cov.out +Server/cover.out +Server/coverage.out +Server/ws_cover.out diff --git a/Client/OwnCord.Client.Tests/Services/CertificateTrustServiceTests.cs b/Client/OwnCord.Client.Tests/Services/CertificateTrustServiceTests.cs new file mode 100644 index 00000000..3f9c7488 --- /dev/null +++ b/Client/OwnCord.Client.Tests/Services/CertificateTrustServiceTests.cs @@ -0,0 +1,225 @@ +using System.IO; +using OwnCord.Client.Services; + +namespace OwnCord.Client.Tests.Services; + +/// +/// Tests for CertificateTrustService — Trust-On-First-Use (TOFU) certificate pinning. +/// Each test uses an isolated temp directory so there is no shared state between tests. +/// +public sealed class CertificateTrustServiceTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + + // Factory so each assertion that needs a "new instance" can create one pointing at the same dir. + private CertificateTrustService NewSvc() => new(_tempDir); + + // ── IsTrusted ───────────────────────────────────────────────────────────── + + [Fact] + public void IsTrusted_FirstUse_AutoTrustsAndReturnsTrue() + { + // Arrange: no stored fingerprint for this host + var svc = NewSvc(); + + // Act: first connection — TOFU should auto-trust + var result = svc.IsTrusted("server1.local:8443", "AABBCC112233"); + + // Assert + Assert.True(result, "First-use should auto-trust the certificate and return true."); + } + + [Fact] + public void IsTrusted_SameFingerprint_ReturnsTrue() + { + // Arrange: trust fingerprint on first use + var svc = NewSvc(); + svc.IsTrusted("server2.local:8443", "FINGERPRINT_A"); + + // Act: same fingerprint presented again + var result = svc.IsTrusted("server2.local:8443", "FINGERPRINT_A"); + + Assert.True(result, "A previously trusted fingerprint must continue to be accepted."); + } + + [Fact] + public void IsTrusted_DifferentFingerprint_ReturnsFalse() + { + // Arrange: trust an initial fingerprint + var svc = NewSvc(); + svc.IsTrusted("server3.local:8443", "FINGERPRINT_ORIGINAL"); + + // Act: different fingerprint — cert was swapped + var result = svc.IsTrusted("server3.local:8443", "FINGERPRINT_ATTACKER"); + + Assert.False(result, "A changed fingerprint must be rejected to prevent MITM."); + } + + [Fact] + public void IsTrusted_NullCertificate_ReturnsFalse() + { + // Arrange: host has an existing trusted fingerprint + var svc = NewSvc(); + svc.TrustFingerprint("server4.local:8443", "FINGERPRINT_OK"); + + // Act: null/empty fingerprint (cert was null) + var resultNull = svc.IsTrusted("server4.local:8443", null!); + var resultEmpty = svc.IsTrusted("server4.local:8443", ""); + + Assert.False(resultNull, "Null fingerprint must be rejected."); + Assert.False(resultEmpty, "Empty fingerprint must be rejected."); + } + + [Fact] + public void IsTrusted_NullOrEmptyHost_ReturnsFalse() + { + var svc = NewSvc(); + + Assert.False(svc.IsTrusted(null!, "FINGERPRINT"), "Null host must return false."); + Assert.False(svc.IsTrusted("", "FINGERPRINT"), "Empty host must return false."); + } + + [Fact] + public void IsTrusted_DifferentHostsSameFingerprint_TrackedIndependently() + { + // Two different hosts can have the same fingerprint — each is independent + var svc = NewSvc(); + svc.IsTrusted("host-a:8443", "SHARED_FINGERPRINT"); + svc.IsTrusted("host-b:8443", "SHARED_FINGERPRINT"); + + // Changing one host's cert must not affect the other + Assert.True(svc.IsTrusted("host-a:8443", "SHARED_FINGERPRINT")); + Assert.True(svc.IsTrusted("host-b:8443", "SHARED_FINGERPRINT")); + Assert.False(svc.IsTrusted("host-a:8443", "NEW_FINGERPRINT")); + Assert.True(svc.IsTrusted("host-b:8443", "SHARED_FINGERPRINT"), "host-b trust must be unaffected."); + } + + // ── TrustFingerprint ────────────────────────────────────────────────────── + + [Fact] + public void TrustFingerprint_StoresFingerprint_CanBeRetrieved() + { + var svc = NewSvc(); + svc.TrustFingerprint("server5.local:8443", "STORED_FP"); + + Assert.Equal("STORED_FP", svc.GetTrustedFingerprint("server5.local:8443")); + } + + [Fact] + public void TrustFingerprint_OverwritesExisting() + { + // Explicitly overwriting — e.g. user manually updated cert trust + var svc = NewSvc(); + svc.TrustFingerprint("server6.local:8443", "OLD_FP"); + svc.TrustFingerprint("server6.local:8443", "NEW_FP"); + + Assert.Equal("NEW_FP", svc.GetTrustedFingerprint("server6.local:8443")); + } + + // ── RemoveTrust ─────────────────────────────────────────────────────────── + + [Fact] + public void RemoveTrust_RemovesStoredFingerprint() + { + var svc = NewSvc(); + svc.TrustFingerprint("server7.local:8443", "FP"); + svc.RemoveTrust("server7.local:8443"); + + Assert.Null(svc.GetTrustedFingerprint("server7.local:8443")); + } + + [Fact] + public void RemoveTrust_AfterRemoval_NextConnectionAutoTrustsAgain() + { + // After trust is cleared, the next connection acts as first-use again + var svc = NewSvc(); + svc.TrustFingerprint("server8.local:8443", "OLD_FP"); + svc.RemoveTrust("server8.local:8443"); + + var result = svc.IsTrusted("server8.local:8443", "NEW_FP"); + + Assert.True(result, "After removing trust, the next fingerprint should be auto-trusted."); + Assert.Equal("NEW_FP", svc.GetTrustedFingerprint("server8.local:8443")); + } + + [Fact] + public void RemoveTrust_NonExistentHost_DoesNotThrow() + { + var svc = NewSvc(); + var ex = Record.Exception(() => svc.RemoveTrust("never-seen.local:8443")); + Assert.Null(ex); + } + + // ── GetTrustedFingerprint ───────────────────────────────────────────────── + + [Fact] + public void GetTrustedFingerprint_UnknownHost_ReturnsNull() + { + var svc = NewSvc(); + Assert.Null(svc.GetTrustedFingerprint("unknown.local:8443")); + } + + // ── Persistence ─────────────────────────────────────────────────────────── + + [Fact] + public void Persistence_FingerprintSurvivesNewInstanceCreation() + { + // Instance 1: store a fingerprint + NewSvc().TrustFingerprint("persist-host:8443", "PERSISTED_FP"); + + // Instance 2: different object, same directory — must read the stored fingerprint + var fp = NewSvc().GetTrustedFingerprint("persist-host:8443"); + Assert.Equal("PERSISTED_FP", fp); + } + + [Fact] + public void Persistence_IsTrustedUsesPersistedData() + { + // First process: trust on first use + NewSvc().IsTrusted("persist2-host:8443", "FIRST_FP"); + + // Second process: different instance must reject a changed fingerprint + var result = NewSvc().IsTrusted("persist2-host:8443", "CHANGED_FP"); + Assert.False(result, "Persisted fingerprint must be enforced across instances."); + } + + [Fact] + public void Persistence_RemoveTrustSurvivesNewInstance() + { + var svc1 = NewSvc(); + svc1.TrustFingerprint("persist3-host:8443", "FP"); + svc1.RemoveTrust("persist3-host:8443"); + + // New instance: trust should be gone + Assert.Null(NewSvc().GetTrustedFingerprint("persist3-host:8443")); + } + + [Fact] + public void Persistence_CreatesDirectoryIfMissing() + { + Assert.False(Directory.Exists(_tempDir)); + NewSvc().TrustFingerprint("server-dir-test:8443", "FP"); + Assert.True(Directory.Exists(_tempDir)); + } + + // ── Fingerprint case-insensitivity ──────────────────────────────────────── + + [Fact] + public void IsTrusted_FingerprintComparison_IsCaseInsensitive() + { + // SHA-256 hex strings may arrive in upper or lower case depending on the source + var svc = NewSvc(); + svc.TrustFingerprint("case-host:8443", "aabbccddeeff"); + + Assert.True(svc.IsTrusted("case-host:8443", "AABBCCDDEEFF"), + "Fingerprint comparison must be case-insensitive."); + Assert.True(svc.IsTrusted("case-host:8443", "aAbBcCdDeEfF"), + "Mixed-case fingerprint must also match."); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } +} diff --git a/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs b/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs index 70524356..c8095466 100644 --- a/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs +++ b/Client/OwnCord.Client.Tests/Services/ChatServiceTests.cs @@ -340,7 +340,7 @@ public class ChatServiceTests Assert.Single(_ws.SentMessages); var sent = JsonDocument.Parse(_ws.SentMessages[0]); - Assert.Equal("typing", sent.RootElement.GetProperty("type").GetString()); + Assert.Equal("typing_start", sent.RootElement.GetProperty("type").GetString()); Assert.Equal(1, sent.RootElement.GetProperty("payload").GetProperty("channel_id").GetInt64()); } diff --git a/Client/OwnCord.Client/App.xaml.cs b/Client/OwnCord.Client/App.xaml.cs index d78c3471..2adfb8dd 100644 --- a/Client/OwnCord.Client/App.xaml.cs +++ b/Client/OwnCord.Client/App.xaml.cs @@ -17,8 +17,9 @@ public partial class App : Application var profileService = new ProfileService(dataDir); var credentialService = new CredentialService(); - var wsService = new WebSocketService(); - var apiClient = ApiClient.CreateWithSelfSignedTls(); + var trustService = new CertificateTrustService(); + var wsService = new WebSocketService(trustService); + var apiClient = ApiClient.CreateWithTofuTls(trustService); var chatService = new ChatService(apiClient, wsService); var connectVm = new ConnectViewModel(profileService, credentialService); diff --git a/Client/OwnCord.Client/AssemblyInfo.cs b/Client/OwnCord.Client/AssemblyInfo.cs index cc29e7f7..adfa6fc0 100644 --- a/Client/OwnCord.Client/AssemblyInfo.cs +++ b/Client/OwnCord.Client/AssemblyInfo.cs @@ -1,5 +1,8 @@ +using System.Runtime.CompilerServices; using System.Windows; +[assembly: InternalsVisibleTo("OwnCord.Client.Tests")] + [assembly:ThemeInfo( ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located //(used if a resource is not found in the page, diff --git a/Client/OwnCord.Client/Services/ApiClient.cs b/Client/OwnCord.Client/Services/ApiClient.cs index 489e4af8..fbb1fd1b 100644 --- a/Client/OwnCord.Client/Services/ApiClient.cs +++ b/Client/OwnCord.Client/Services/ApiClient.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using OwnCord.Client.Models; @@ -26,13 +27,28 @@ public sealed class ApiClient : IApiClient } /// - /// Creates an ApiClient with a handler that accepts self-signed TLS certificates. + /// Creates an ApiClient that uses Trust-On-First-Use (TOFU) certificate pinning. + /// On first connection to a host, the server's self-signed certificate SHA-256 fingerprint + /// is stored. Subsequent connections must present the same fingerprint. /// - public static ApiClient CreateWithSelfSignedTls() + public static ApiClient CreateWithTofuTls(ICertificateTrustService trustService) { var handler = new HttpClientHandler { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true + ServerCertificateCustomValidationCallback = (_, cert, _, _) => + { + if (cert == null) return false; + var fingerprint = cert.GetCertHashString(HashAlgorithmName.SHA256); + // Extract host:port from the request — the cert object has no request URL, + // but the HttpClient passes the request URI via the first argument (HttpRequestMessage). + // Unfortunately the standard callback signature does not expose it directly, + // so we store it as an ambient value set before each request. + // Fail closed: if the host context is missing, reject the connection. + // Never fall back to cert.Subject — an attacker controls that value. + var host = TofuHostContext.CurrentHost; + if (host == null) return false; + return trustService.IsTrusted(host, fingerprint); + } }; var http = new HttpClient(handler); http.DefaultRequestHeaders.Add("User-Agent", "OwnCord-Client/0.1.0"); @@ -55,11 +71,19 @@ public sealed class ApiClient : IApiClient public async Task LogoutAsync(string host, string token, CancellationToken ct = default) { - var request = new HttpRequestMessage(HttpMethod.Post, BuildUrl(host, "/api/v1/auth/logout")); - request.Headers.Add("Authorization", $"Bearer {token}"); - var response = await _http.SendAsync(request, ct); - if (!response.IsSuccessStatusCode) - await ThrowApiExceptionAsync(response, ct); + TofuHostContext.CurrentHost = NormalizeHost(host); + try + { + var request = new HttpRequestMessage(HttpMethod.Post, BuildUrl(host, "/api/v1/auth/logout")); + request.Headers.Add("Authorization", $"Bearer {token}"); + var response = await _http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + await ThrowApiExceptionAsync(response, ct); + } + finally + { + TofuHostContext.CurrentHost = null; + } } public async Task GetMeAsync(string host, string token, CancellationToken ct = default) @@ -86,8 +110,16 @@ public sealed class ApiClient : IApiClient public async Task HealthCheckAsync(string host, CancellationToken ct = default) { - var response = await _http.GetAsync(BuildUrl(host, "/health"), ct); - return await ReadOrThrowAsync(response, ct); + TofuHostContext.CurrentHost = NormalizeHost(host); + try + { + var response = await _http.GetAsync(BuildUrl(host, "/health"), ct); + return await ReadOrThrowAsync(response, ct); + } + finally + { + TofuHostContext.CurrentHost = null; + } } // ── Helpers ────────────────────────────────────────────────────────────── @@ -114,16 +146,32 @@ public sealed class ApiClient : IApiClient private async Task PostJsonAsync(string host, string path, object body, CancellationToken ct) { - var json = JsonSerializer.Serialize(body, JsonOpts); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await _http.PostAsync(BuildUrl(host, path), content, ct); + TofuHostContext.CurrentHost = NormalizeHost(host); + try + { + var json = JsonSerializer.Serialize(body, JsonOpts); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await _http.PostAsync(BuildUrl(host, path), content, ct); + } + finally + { + TofuHostContext.CurrentHost = null; + } } private async Task GetAuthenticatedAsync(string host, string path, string token, CancellationToken ct) { - var request = new HttpRequestMessage(HttpMethod.Get, BuildUrl(host, path)); - request.Headers.Add("Authorization", $"Bearer {token}"); - return await _http.SendAsync(request, ct); + TofuHostContext.CurrentHost = NormalizeHost(host); + try + { + var request = new HttpRequestMessage(HttpMethod.Get, BuildUrl(host, path)); + request.Headers.Add("Authorization", $"Bearer {token}"); + return await _http.SendAsync(request, ct); + } + finally + { + TofuHostContext.CurrentHost = null; + } } private static async Task ReadOrThrowAsync(HttpResponseMessage response, CancellationToken ct) diff --git a/Client/OwnCord.Client/Services/CertificateTrustService.cs b/Client/OwnCord.Client/Services/CertificateTrustService.cs new file mode 100644 index 00000000..f50ea150 --- /dev/null +++ b/Client/OwnCord.Client/Services/CertificateTrustService.cs @@ -0,0 +1,159 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; + +namespace OwnCord.Client.Services; + +/// +/// Trust-On-First-Use (TOFU) certificate pinning service. +/// Fingerprints are persisted as a JSON file in the application data directory so +/// that trust decisions survive application restarts. +/// +/// Storage format: a flat JSON object mapping host strings to SHA-256 hex fingerprints, +/// e.g. { "server.local:8443": "AABBCC..." } +/// +public sealed class CertificateTrustService : ICertificateTrustService +{ + private readonly string _dir; + private readonly string _filePath; + private readonly SemaphoreSlim _lock = new(1, 1); + + // ── Constructors ────────────────────────────────────────────────────────── + + public CertificateTrustService() + : this(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OwnCord", + "certs")) { } + + /// Internal constructor allowing an isolated directory for unit tests. + internal CertificateTrustService(string dir) + { + _dir = dir; + _filePath = Path.Combine(_dir, "trusted_certs.json"); + } + + // ── ICertificateTrustService ────────────────────────────────────────────── + + /// + public bool IsTrusted(string host, string fingerprint) + { + if (string.IsNullOrEmpty(host)) return false; + if (string.IsNullOrEmpty(fingerprint)) return false; + + _lock.Wait(); + try + { + var store = Load(); + + if (!store.TryGetValue(host, out var stored)) + { + // First use — auto-trust (TOFU) + var updated = new Dictionary(store, StringComparer.OrdinalIgnoreCase) + { + [host] = fingerprint + }; + Save(updated); + return true; + } + + return string.Equals(stored, fingerprint, StringComparison.OrdinalIgnoreCase); + } + finally + { + _lock.Release(); + } + } + + /// + public void TrustFingerprint(string host, string fingerprint) + { + if (string.IsNullOrEmpty(host)) + throw new ArgumentException("Host must not be null or empty.", nameof(host)); + if (string.IsNullOrEmpty(fingerprint)) + throw new ArgumentException("Fingerprint must not be null or empty.", nameof(fingerprint)); + + _lock.Wait(); + try + { + var store = Load(); + var updated = new Dictionary(store, StringComparer.OrdinalIgnoreCase) + { + [host] = fingerprint + }; + Save(updated); + } + finally + { + _lock.Release(); + } + } + + /// + public void RemoveTrust(string host) + { + if (string.IsNullOrEmpty(host)) return; + + _lock.Wait(); + try + { + var store = Load(); + if (!store.ContainsKey(host)) return; + + var updated = new Dictionary(store, StringComparer.OrdinalIgnoreCase); + updated.Remove(host); + Save(updated); + } + finally + { + _lock.Release(); + } + } + + /// + public string? GetTrustedFingerprint(string host) + { + if (string.IsNullOrEmpty(host)) return null; + + var store = Load(); + return store.TryGetValue(host, out var fp) ? fp : null; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true + }; + + /// + /// Reads the trust store from disk. Returns an empty dictionary if the file does not exist. + /// Throws if the file exists but is corrupt/unreadable — this prevents a silent TOFU + /// downgrade where a corrupt store causes all hosts to be re-auto-trusted. + /// + private Dictionary Load() + { + if (!File.Exists(_filePath)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var json = File.ReadAllText(_filePath); + var raw = JsonSerializer.Deserialize>(json); + return raw is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(raw, StringComparer.OrdinalIgnoreCase); + } + + /// Writes the trust store to disk atomically via a temp-file swap. + private void Save(Dictionary store) + { + Directory.CreateDirectory(_dir); + + var json = JsonSerializer.Serialize(store, JsonOpts); + + // Write to a temp file first, then replace, to avoid corruption on crash + var tmp = _filePath + ".tmp"; + File.WriteAllText(tmp, json); + File.Move(tmp, _filePath, overwrite: true); + } +} diff --git a/Client/OwnCord.Client/Services/ChatService.cs b/Client/OwnCord.Client/Services/ChatService.cs index 897427d5..0d17f4a4 100644 --- a/Client/OwnCord.Client/Services/ChatService.cs +++ b/Client/OwnCord.Client/Services/ChatService.cs @@ -34,6 +34,9 @@ public sealed class ChatService : IChatService public event Action? ErrorReceived; public event Action? ServerRestarting; public event Action? MemberJoined; + public event Action? ChannelCreated; + public event Action? ChannelUpdated; + public event Action? ChannelDeleted; public event Action? ConnectionLost; public ChatService(IApiClient api, IWebSocketService ws) @@ -89,7 +92,23 @@ public sealed class ChatService : IChatService var wsUri = $"wss://{ApiClient.NormalizeHost(host)}/api/v1/ws"; await _ws.ConnectAsync(wsUri, token, ct); - _ = _ws.RunReceiveLoopAsync(ct); + _ = RunReceiveLoopWithErrorHandlingAsync(ct); + } + + private async Task RunReceiveLoopWithErrorHandlingAsync(CancellationToken ct) + { + try + { + await _ws.RunReceiveLoopAsync(ct); + } + catch (OperationCanceledException) + { + // Normal shutdown — ignore + } + catch (Exception ex) + { + ConnectionLost?.Invoke($"Receive loop error: {ex.Message}"); + } } public Task DisconnectWebSocketAsync() @@ -124,7 +143,7 @@ public sealed class ChatService : IChatService { var envelope = new { - type = "typing", + type = "typing_start", payload = new { channel_id = channelId } }; return _ws.SendAsync(envelope, ct); @@ -187,6 +206,17 @@ public sealed class ChatService : IChatService case "member_join": MemberJoined?.Invoke(Deserialize(envelope)); break; + case "channel_create": + ChannelCreated?.Invoke(Deserialize(envelope)); + break; + case "channel_update": + ChannelUpdated?.Invoke(Deserialize(envelope)); + break; + case "channel_delete": + var delPayload = envelope.Payload?.Deserialize(); + if (delPayload?.TryGetProperty("id", out var idEl) == true) + ChannelDeleted?.Invoke(idEl.GetInt64()); + break; // Unknown types silently ignored — forward compatibility } } diff --git a/Client/OwnCord.Client/Services/ICertificateTrustService.cs b/Client/OwnCord.Client/Services/ICertificateTrustService.cs new file mode 100644 index 00000000..f510221d --- /dev/null +++ b/Client/OwnCord.Client/Services/ICertificateTrustService.cs @@ -0,0 +1,25 @@ +namespace OwnCord.Client.Services; + +/// +/// Trust-On-First-Use (TOFU) certificate pinning service. +/// On the first connection to a host, the certificate fingerprint is automatically +/// trusted and stored. On subsequent connections, the stored fingerprint must match. +/// +public interface ICertificateTrustService +{ + /// + /// Returns true if the given fingerprint is trusted for the host. + /// On first use (no stored fingerprint), automatically trusts and stores the fingerprint. + /// Returns false if a different fingerprint was previously stored for this host. + /// + bool IsTrusted(string host, string fingerprint); + + /// Explicitly stores a fingerprint as trusted for the given host. + void TrustFingerprint(string host, string fingerprint); + + /// Removes any stored trust record for the given host. + void RemoveTrust(string host); + + /// Returns the stored fingerprint for the host, or null if none is stored. + string? GetTrustedFingerprint(string host); +} diff --git a/Client/OwnCord.Client/Services/IChatService.cs b/Client/OwnCord.Client/Services/IChatService.cs index ea4b9e45..48a08d78 100644 --- a/Client/OwnCord.Client/Services/IChatService.cs +++ b/Client/OwnCord.Client/Services/IChatService.cs @@ -50,5 +50,8 @@ public interface IChatService event Action? ErrorReceived; event Action? ServerRestarting; event Action? MemberJoined; + event Action? ChannelCreated; + event Action? ChannelUpdated; + event Action? ChannelDeleted; event Action? ConnectionLost; } diff --git a/Client/OwnCord.Client/Services/TofuHostContext.cs b/Client/OwnCord.Client/Services/TofuHostContext.cs new file mode 100644 index 00000000..c444a3b6 --- /dev/null +++ b/Client/OwnCord.Client/Services/TofuHostContext.cs @@ -0,0 +1,22 @@ +using System.Threading; + +namespace OwnCord.Client.Services; + +/// +/// Async-aware ambient context that carries the current server host into the +/// . +/// +/// Uses instead of [ThreadStatic] so the value flows +/// correctly through async continuations and thread-pool threads used by HttpClient. +/// +internal static class TofuHostContext +{ + private static readonly AsyncLocal _currentHost = new(); + + /// Gets or sets the host (host:port) for the in-progress request in this async flow. + internal static string? CurrentHost + { + get => _currentHost.Value; + set => _currentHost.Value = value; + } +} diff --git a/Client/OwnCord.Client/Services/WebSocketService.cs b/Client/OwnCord.Client/Services/WebSocketService.cs index f4e705fb..cc9c9fa1 100644 --- a/Client/OwnCord.Client/Services/WebSocketService.cs +++ b/Client/OwnCord.Client/Services/WebSocketService.cs @@ -1,6 +1,7 @@ using System.IO; using System.Net.WebSockets; using System.Runtime.CompilerServices; +using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -8,8 +9,14 @@ namespace OwnCord.Client.Services; public sealed class WebSocketService : IWebSocketService, IDisposable { + private readonly ICertificateTrustService _trustService; private ClientWebSocket? _ws; + public WebSocketService(ICertificateTrustService trustService) + { + _trustService = trustService; + } + public bool IsConnected => _ws?.State == WebSocketState.Open; public WebSocketState State => _ws?.State ?? WebSocketState.None; @@ -21,8 +28,17 @@ public sealed class WebSocketService : IWebSocketService, IDisposable _ws?.Dispose(); _ws = new ClientWebSocket(); - // Accept self-signed TLS certificates (server generates self-signed by default). - _ws.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true; + var host = ExtractHost(uri); + + // Trust-On-First-Use (TOFU) certificate pinning. + // On first connection to a host, the certificate SHA-256 fingerprint is stored. + // On subsequent connections, the fingerprint must match the stored value. + _ws.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => + { + if (cert == null) return false; + var fingerprint = cert.GetCertHashString(HashAlgorithmName.SHA256); + return _trustService.IsTrusted(host, fingerprint); + }; await _ws.ConnectAsync(new Uri(uri), ct); var auth = JsonSerializer.Serialize(new { type = "auth", payload = new { token } }); @@ -95,6 +111,23 @@ public sealed class WebSocketService : IWebSocketService, IDisposable await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnect", default); } + /// + /// Extracts "host:port" from a WebSocket URI for use as the trust store key. + /// e.g. "wss://server.local:8443/ws" → "server.local:8443" + /// + private static string ExtractHost(string uri) + { + try + { + var u = new Uri(uri); + return u.IsDefaultPort ? u.Host : $"{u.Host}:{u.Port}"; + } + catch + { + return uri; + } + } + private async Task SendRawAsync(string text, CancellationToken ct) { if (_ws is null) return; diff --git a/Client/OwnCord.Client/ViewModels/MainViewModel.cs b/Client/OwnCord.Client/ViewModels/MainViewModel.cs index d82c4085..12762d94 100644 --- a/Client/OwnCord.Client/ViewModels/MainViewModel.cs +++ b/Client/OwnCord.Client/ViewModels/MainViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Threading; using System.Windows; using System.Windows.Input; using OwnCord.Client.Models; @@ -13,6 +14,7 @@ public sealed class MainViewModel : ViewModelBase private string _messageInput = string.Empty; private bool _isTyping; private string? _connectionStatus; + private Timer? _typingTimer; public MainViewModel() { @@ -34,6 +36,9 @@ public sealed class MainViewModel : ViewModelBase chat.ChatEdited += p => RunOnUI(() => OnChatEdited(p)); chat.ChatDeleted += p => RunOnUI(() => OnChatDeleted(p)); chat.MemberJoined += p => RunOnUI(() => OnMemberJoined(p)); + chat.ChannelCreated += p => RunOnUI(() => OnChannelCreated(p)); + chat.ChannelUpdated += p => RunOnUI(() => OnChannelUpdated(p)); + chat.ChannelDeleted += id => RunOnUI(() => OnChannelDeleted(id)); chat.ConnectionLost += r => RunOnUI(() => OnConnectionLost(r)); } @@ -226,7 +231,7 @@ public sealed class MainViewModel : ViewModelBase ); Messages.Add(msg); } - else if (payload.ChannelId != SelectedChannel?.Id) + else { // Increment unread for non-active channel UpdateUnreadCount(payload.ChannelId, GetUnreadCount(payload.ChannelId) + 1); @@ -236,7 +241,11 @@ public sealed class MainViewModel : ViewModelBase private void OnTyping(TypingPayload payload) { if (SelectedChannel is not null && payload.ChannelId == SelectedChannel.Id) + { ShowTyping(payload.Username); + _typingTimer?.Dispose(); + _typingTimer = new Timer(_ => RunOnUI(HideTyping), null, 5000, Timeout.Infinite); + } } private void OnPresence(PresencePayload payload) @@ -284,6 +293,42 @@ public sealed class MainViewModel : ViewModelBase Members.Add(new User(payload.Id, payload.Username, payload.Avatar, payload.RoleId, status)); } + private void OnChannelCreated(ChannelEventPayload payload) + { + if (Channels.Any(c => c.Id == payload.Id)) return; + var type = payload.Type switch + { + "voice" => ChannelType.Voice, + "announcement" => ChannelType.Announcement, + _ => ChannelType.Text + }; + Channels.Add(new Channel(payload.Id, payload.Name, type, payload.Category, payload.Position, 0, null)); + } + + private void OnChannelUpdated(ChannelEventPayload payload) + { + var idx = Channels.ToList().FindIndex(c => c.Id == payload.Id); + if (idx < 0) return; + var type = payload.Type switch + { + "voice" => ChannelType.Voice, + "announcement" => ChannelType.Announcement, + _ => ChannelType.Text + }; + Channels[idx] = new Channel(payload.Id, payload.Name, type, payload.Category, payload.Position, Channels[idx].UnreadCount, Channels[idx].LastMessageId); + } + + private void OnChannelDeleted(long channelId) + { + var ch = Channels.FirstOrDefault(c => c.Id == channelId); + if (ch is not null) + { + Channels.Remove(ch); + if (SelectedChannel?.Id == channelId) + SelectedChannel = Channels.FirstOrDefault(); + } + } + private void OnConnectionLost(string reason) { ConnectionStatus = "Disconnected — reconnecting..."; diff --git a/Server/admin/admin.go b/Server/admin/admin.go index 35a83206..2374389b 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -47,6 +47,10 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater. } r.Get("/", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + // The admin SPA uses inline