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