From b263291dc25f88b4176f4091addcb852dbe849c9 Mon Sep 17 00:00:00 2001 From: Creeper Lv Date: Mon, 1 Jun 2026 05:49:08 +1000 Subject: [PATCH] Updated with Antigravity. --- Server/Endpoints/NodeEndpoints.cs | 15 +- Server/Endpoints/SyncEndpoints.cs | 253 ++++++++++++++++++++++++------ Server/Program.cs | 78 ++++++--- Server/Security/AuthHelper.cs | 13 ++ Server/Security/PeerCache.cs | 49 +++++- Server/Security/RsaKeyManager.cs | 107 +++++++++++++ Server/appsettings.json | 3 +- 7 files changed, 436 insertions(+), 82 deletions(-) create mode 100644 Server/Security/RsaKeyManager.cs diff --git a/Server/Endpoints/NodeEndpoints.cs b/Server/Endpoints/NodeEndpoints.cs index ad72163..d460d19 100644 --- a/Server/Endpoints/NodeEndpoints.cs +++ b/Server/Endpoints/NodeEndpoints.cs @@ -184,14 +184,15 @@ public static class NodeEndpoints db.NodeKeys.Add(nodeKey); await db.SaveChangesAsync(); - // Broadcast change to other servers in background _ = Task.Run(async () => { try { using var scope = context.RequestServices.CreateScope(); var scopeDb = scope.ServiceProvider.GetRequiredService(); - await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, httpClient); + var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); + var configuration = scope.ServiceProvider.GetRequiredService(); + await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); } catch (Exception ex) { @@ -261,14 +262,15 @@ public static class NodeEndpoints node.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - // Broadcast change to other servers in background _ = Task.Run(async () => { try { using var scope = context.RequestServices.CreateScope(); var scopeDb = scope.ServiceProvider.GetRequiredService(); - await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, httpClient); + var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); + var configuration = scope.ServiceProvider.GetRequiredService(); + await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); } catch (Exception ex) { @@ -300,14 +302,15 @@ public static class NodeEndpoints node.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - // Broadcast change to other servers in background _ = Task.Run(async () => { try { using var scope = context.RequestServices.CreateScope(); var scopeDb = scope.ServiceProvider.GetRequiredService(); - await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, httpClient); + var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); + var configuration = scope.ServiceProvider.GetRequiredService(); + await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); } catch (Exception ex) { diff --git a/Server/Endpoints/SyncEndpoints.cs b/Server/Endpoints/SyncEndpoints.cs index 0ffc072..9aa4c3a 100644 --- a/Server/Endpoints/SyncEndpoints.cs +++ b/Server/Endpoints/SyncEndpoints.cs @@ -21,31 +21,80 @@ namespace SNote.Server.Endpoints; public static class SyncEndpoints { + public static string? UpstreamSessionToken { get; set; } = null; public static void MapSyncEndpoints(this IEndpointRouteBuilder routes) { - // 0. Heartbeat ping endpoint - routes.MapGet("/api/sync/ping", () => Results.Ok(new { status = "Healthy" })); - - // 1. Handshake Endpoint: Receive registration of downstream peers - routes.MapPost("/api/sync/register-peer", async (RegisterPeerRequest req, HttpContext context, PeerCache peerCache, CertificateManager certManager) => + // 0. Heartbeat ping endpoint: downstream returns its public key, current timestamp, and a signature (token) of the timestamp + routes.MapGet("/api/sync/ping", (RsaKeyManager rsaKeyManager) => { - if (string.IsNullOrWhiteSpace(req.PeerUrl)) + try { - return Results.BadRequest("Peer URL is required."); + var timestamp = DateTime.UtcNow.ToString("o"); + var dataToSign = System.Text.Encoding.UTF8.GetBytes(timestamp); + + var privateRsa = rsaKeyManager.GetPrivateKey(); + var signatureBytes = privateRsa.SignData(dataToSign, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); + var token = Convert.ToBase64String(signatureBytes); + + return Results.Ok(new + { + publicKey = rsaKeyManager.GetPublicKeyPem(), + timestamp = timestamp, + token = token + }); + } + catch (Exception ex) + { + return Results.Json(new { error = $"Heartbeat generation failed: {ex.Message}" }, statusCode: 500); + } + }); + + // 1. Handshake Endpoint: Receive registration of downstream peers with RSA asymmetric validation + routes.MapPost("/api/sync/register-peer", async (RegisterPeerRequest req, HttpContext context, PeerCache peerCache, RsaKeyManager rsaKeyManager) => + { + if (string.IsNullOrWhiteSpace(req.PeerUrl) || string.IsNullOrWhiteSpace(req.EncryptedSecret) || + string.IsNullOrWhiteSpace(req.PublicKey) || string.IsNullOrWhiteSpace(req.Signature)) + { + return Results.BadRequest("Missing required handshake parameters."); } - // Authenticate server-to-server JWT - if (!AuthHelper.IsServerAuthenticated(context, certManager)) + try { - return Results.Json(new { error = "Unauthorized server connection handshake." }, statusCode: 401); - } + // 1. Decrypt the secret using our Private Key + var localPrivateRsa = rsaKeyManager.GetPrivateKey(); + var encryptedBytes = Convert.FromBase64String(req.EncryptedSecret); + var decryptedBytes = localPrivateRsa.Decrypt(encryptedBytes, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256); + var secret = System.Text.Encoding.UTF8.GetString(decryptedBytes); - peerCache.RegisterPeer(req.PeerUrl); - return Results.Ok(new { message = "Server peer registered successfully." }); + // 2. Verify signature using downstream's Public Key + using var peerPublicRsa = System.Security.Cryptography.RSA.Create(); + peerPublicRsa.ImportFromPem(req.PublicKey); + + var dataToVerify = System.Text.Encoding.UTF8.GetBytes($"{req.PeerUrl}|{secret}"); + var signatureBytes = Convert.FromBase64String(req.Signature); + var verified = peerPublicRsa.VerifyData(dataToVerify, signatureBytes, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); + + if (!verified) + { + return Results.Json(new { error = "Asymmetric signature verification failed." }, statusCode: 401); + } + + // 3. Issue secure session token and register peer details + var sessionToken = Guid.NewGuid().ToString(); + peerCache.RegisterPeer(req.PeerUrl, sessionToken, req.PublicKey); + + Console.WriteLine($"[Handshake] Successfully validated child server '{req.PeerUrl}' via RSA and issued token."); + return Results.Ok(new { sessionToken = sessionToken }); + } + catch (Exception ex) + { + Console.WriteLine($"[Handshake] Handshake registration error: {ex.Message}"); + return Results.Json(new { error = $"Handshake validation error: {ex.Message}" }, statusCode: 400); + } }); // 2. Full Database pulling (bootstrapping new server node) - routes.MapPost("/api/sync/pull-database", async (HttpContext context, ServerDbContext db, IConfiguration config, CertificateManager certManager) => + routes.MapPost("/api/sync/pull-database", async (HttpContext context, ServerDbContext db, IConfiguration config, PeerCache peerCache) => { var enablePull = config.GetValue("EnableFullDbPull", false); if (!enablePull) @@ -53,9 +102,9 @@ public static class SyncEndpoints return Results.Json(new { error = "Full database pulling is disabled on this server." }, statusCode: 403); } - if (!AuthHelper.IsServerAuthenticated(context, certManager)) + if (!AuthHelper.IsServerTokenValid(context, peerCache)) { - return Results.Json(new { error = "Unauthorized server node." }, statusCode: 401); + return Results.Json(new { error = "Unauthorized server token." }, statusCode: 401); } var users = await db.Users.ToListAsync(); @@ -70,9 +119,9 @@ public static class SyncEndpoints }); // 3. Synchronize nodes states - routes.MapPost("/api/sync/nodes", async (SyncExchangeRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager) => + routes.MapPost("/api/sync/nodes", async (SyncExchangeRequest req, HttpContext context, ServerDbContext db, PeerCache peerCache) => { - if (!AuthHelper.IsServerAuthenticated(context, certManager)) + if (!AuthHelper.IsServerTokenValid(context, peerCache)) { return Results.Unauthorized(); } @@ -120,9 +169,9 @@ public static class SyncEndpoints }); // 4. Receive full node pushes during sync - routes.MapPost("/api/sync/push", async (List items, HttpContext context, ServerDbContext db, CertificateManager certManager) => + routes.MapPost("/api/sync/push", async (List items, HttpContext context, ServerDbContext db, PeerCache peerCache) => { - if (!AuthHelper.IsServerAuthenticated(context, certManager)) + if (!AuthHelper.IsServerTokenValid(context, peerCache)) { return Results.Unauthorized(); } @@ -160,9 +209,9 @@ public static class SyncEndpoints }); // 5. Receive broadcast of changes (broadcasts single node updates) - routes.MapPost("/api/sync/broadcast", async (SyncPushItem item, HttpContext context, ServerDbContext db, PeerCache peerCache, CertificateManager certManager, HttpClient httpClient) => + routes.MapPost("/api/sync/broadcast", async (SyncPushItem item, HttpContext context, ServerDbContext db, PeerCache peerCache, RsaKeyManager rsaKeyManager, HttpClient httpClient) => { - if (!AuthHelper.IsServerAuthenticated(context, certManager)) + if (!AuthHelper.IsServerTokenValid(context, peerCache)) { return Results.Unauthorized(); } @@ -216,7 +265,8 @@ public static class SyncEndpoints { _ = Task.Run(async () => { - await BroadcastNodeChangeAsync(item.Node.Uuid, db, peerCache, certManager, httpClient, broadcastId); + var configuration = context.RequestServices.GetRequiredService(); + await BroadcastNodeChangeAsync(item.Node.Uuid, db, peerCache, rsaKeyManager, configuration, httpClient, broadcastId); }); } } @@ -225,33 +275,68 @@ public static class SyncEndpoints }); } - // Handshake: Tells the destination server who we are and registers us - public static async Task RegisterPeerWithDestinationAsync(string destinationUrl, string localUrl, CertificateManager certManager, HttpClient httpClient) + // Handshake: Tells the destination server who we are and registers us using RSA asymmetric verification + public static async Task RegisterPeerWithDestinationAsync(string destinationUrl, string localUrl, RsaKeyManager rsaKeyManager, IConfiguration configuration, HttpClient httpClient) { try { - Console.WriteLine($"[Handshake] Registering our address '{localUrl}' with destination '{destinationUrl}'..."); + Console.WriteLine($"[Handshake] Performing RSA handshake with destination '{destinationUrl}'..."); + + // 1. Get public key from RsaKeyManager + var upstreamPublicKey = rsaKeyManager.GetPublicKeyPem(); + if (string.IsNullOrEmpty(upstreamPublicKey)) + { + Console.WriteLine("[Handshake] Error: Public key is not loaded in RsaKeyManager. RSA handshake cannot proceed."); + return; + } + var cleanDest = destinationUrl.TrimEnd('/') + "/api/sync/register-peer"; - var payload = new RegisterPeerRequest(localUrl); + // 2. Generate secret challenge + var secret = Guid.NewGuid().ToString(); + + // 3. Encrypt secret challenge using upstream's public key + using var upstreamRsa = System.Security.Cryptography.RSA.Create(); + upstreamRsa.ImportFromPem(upstreamPublicKey); + + var secretBytes = System.Text.Encoding.UTF8.GetBytes(secret); + var encryptedBytes = upstreamRsa.Encrypt(secretBytes, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256); + var encryptedSecretBase64 = Convert.ToBase64String(encryptedBytes); + + // 4. Sign payload (localUrl + secret) using our own private key + var localPrivateRsa = rsaKeyManager.GetPrivateKey(); + var dataToSign = System.Text.Encoding.UTF8.GetBytes($"{localUrl}|{secret}"); + var signatureBytes = localPrivateRsa.SignData(dataToSign, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); + var signatureBase64 = Convert.ToBase64String(signatureBytes); + + // 5. Send registration handshake request + var payload = new RegisterPeerRequest(localUrl, encryptedSecretBase64, rsaKeyManager.GetPublicKeyPem(), signatureBase64); var json = JsonSerializer.Serialize(payload); var request = new HttpRequestMessage(HttpMethod.Post, cleanDest) { - Content = new StringContent(json, Encoding.UTF8, "application/json") + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") }; - // Generate server-to-server JWT signature - var token = AuthHelper.GenerateServerToken(certManager); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await httpClient.SendAsync(request); if (response.IsSuccessStatusCode) { - Console.WriteLine("[Handshake] Successfully registered with destination server."); + var responseContent = await response.Content.ReadAsStringAsync(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var responseData = JsonSerializer.Deserialize(responseContent, options); + + if (responseData != null && !string.IsNullOrEmpty(responseData.SessionToken)) + { + UpstreamSessionToken = responseData.SessionToken; + Console.WriteLine($"[Handshake] Successfully handshaked and obtained Session Token: {UpstreamSessionToken}"); + } + else + { + Console.WriteLine("[Handshake] Handshake warning: Handshake returned success but no session token was found."); + } } else { - Console.WriteLine($"[Handshake] Handshake failed: {response.StatusCode}"); + Console.WriteLine($"[Handshake] Handshake failed with status: {response.StatusCode}"); } } catch (Exception ex) @@ -260,8 +345,8 @@ public static class SyncEndpoints } } - // Broadcast a node change to both database and dynamic in-memory cached peer servers - public static async Task BroadcastNodeChangeAsync(string nodeUuid, ServerDbContext db, PeerCache peerCache, CertificateManager certManager, HttpClient httpClient, string? broadcastId = null) + // Broadcast a node change to both database and dynamic in-memory cached peer servers with token security + public static async Task BroadcastNodeChangeAsync(string nodeUuid, ServerDbContext db, PeerCache peerCache, RsaKeyManager rsaKeyManager, IConfiguration configuration, HttpClient httpClient, string? broadcastId = null) { var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == nodeUuid); if (node == null) return; @@ -277,24 +362,45 @@ public static class SyncEndpoints var payload = new SyncPushItem(node, acls, keys, bId); var json = JsonSerializer.Serialize(payload); - // Fetch database configured peers + // Fetch database configured peers (upstream destination) var dbPeers = await db.PeerServers.Select(p => p.Url).ToListAsync(); - // Fetch dynamically handshake-registered in-memory peers + // Fetch dynamically handshake-registered in-memory peers (downstream child nodes) var dynamicPeers = peerCache.GetPeers(); // Combine peers var allPeers = dbPeers.Union(dynamicPeers, StringComparer.OrdinalIgnoreCase).ToList(); + var localUrl = configuration["Sync:LocalServerUrl"] ?? ""; + var destUrl = configuration["Sync:DestinationServerUrl"] ?? ""; + foreach (var peerUrl in allPeers) { try { var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/broadcast"); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); - var token = AuthHelper.GenerateServerToken(certManager); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + // Attach custom headers for secure authentication + request.Headers.Add("X-Server-Url", localUrl); + + if (string.Equals(peerUrl, destUrl, StringComparison.OrdinalIgnoreCase)) + { + // Upstream communication: pass the token we obtained during handshake! + if (!string.IsNullOrEmpty(UpstreamSessionToken)) + { + request.Headers.Add("X-Server-Token", UpstreamSessionToken); + } + } + else + { + // Downstream communication: pass the session token we issued to this child! + var token = peerCache.GetToken(peerUrl); + if (!string.IsNullOrEmpty(token)) + { + request.Headers.Add("X-Server-Token", token); + } + } var response = await httpClient.SendAsync(request); Console.WriteLine($"Broadcast to {peerUrl}: {response.StatusCode}"); @@ -306,8 +412,8 @@ public static class SyncEndpoints } } - // Periodically pings downstream peer servers in PeerCache to ensure they are online - public static async Task StartHeartbeatPingerAsync(PeerCache peerCache, CertificateManager certManager, HttpClient httpClient, System.Threading.CancellationToken token) + // Periodically pings downstream peer servers in PeerCache to ensure they are online and authentic + public static async Task StartHeartbeatPingerAsync(PeerCache peerCache, RsaKeyManager rsaKeyManager, HttpClient httpClient, System.Threading.CancellationToken token) { Console.WriteLine("[Heartbeat] Starting periodic downstream heartbeat pinger..."); while (!token.IsCancellationRequested) @@ -320,14 +426,11 @@ public static class SyncEndpoints var peers = peerCache.GetPeers(); if (peers.Count == 0) continue; - var jwtToken = AuthHelper.GenerateServerToken(certManager); - foreach (var peerUrl in peers) { try { var request = new HttpRequestMessage(HttpMethod.Get, $"{peerUrl.TrimEnd('/')}/api/sync/ping"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken); // Set a short timeout (e.g. 5 seconds) to avoid blocking using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(5)); @@ -338,7 +441,48 @@ public static class SyncEndpoints { Console.WriteLine($"[Heartbeat] Peer {peerUrl} ping failed with status code: {response.StatusCode}. Evicting..."); peerCache.RemovePeer(peerUrl); + continue; } + + var responseContent = await response.Content.ReadAsStringAsync(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var pingData = JsonSerializer.Deserialize(responseContent, options); + + if (pingData == null || string.IsNullOrEmpty(pingData.PublicKey) || + string.IsNullOrEmpty(pingData.Timestamp) || string.IsNullOrEmpty(pingData.Token)) + { + Console.WriteLine($"[Heartbeat] Peer {peerUrl} returned invalid heartbeat response format. Evicting..."); + peerCache.RemovePeer(peerUrl); + continue; + } + + // 1. Verify that the returned Public Key matches the registered Public Key in our cache! + var expectedPublicKey = peerCache.GetPublicKey(peerUrl); + if (string.IsNullOrEmpty(expectedPublicKey) || + !string.Equals(expectedPublicKey.Trim(), pingData.PublicKey.Trim(), StringComparison.Ordinal)) + { + Console.WriteLine($"[Heartbeat] Peer {peerUrl} public key mismatch! Security warning. Evicting..."); + peerCache.RemovePeer(peerUrl); + continue; + } + + // 2. Verify signature ("token") over the timestamp + using var peerPublicRsa = System.Security.Cryptography.RSA.Create(); + peerPublicRsa.ImportFromPem(pingData.PublicKey); + + var dataToVerify = System.Text.Encoding.UTF8.GetBytes(pingData.Timestamp); + var signatureBytes = Convert.FromBase64String(pingData.Token); + var verified = peerPublicRsa.VerifyData(dataToVerify, signatureBytes, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); + + if (!verified) + { + Console.WriteLine($"[Heartbeat] Peer {peerUrl} signature verification failed! Security warning. Evicting..."); + peerCache.RemovePeer(peerUrl); + continue; + } + + // Heartbeat successful! + Console.WriteLine($"[Heartbeat] Peer {peerUrl} is healthy and authentic."); } catch (Exception ex) { @@ -438,15 +582,15 @@ public static class SyncEndpoints } } - public static async Task BootstrapFromPeerServerAsync(string peerUrl, ServerDbContext db, CertificateManager certManager, HttpClient httpClient) + public static async Task BootstrapFromPeerServerAsync(string peerUrl, string localUrl, string sessionToken, ServerDbContext db, HttpClient httpClient) { try { Console.WriteLine($"Performing full database pulling bootstrap from {peerUrl}..."); - var token = AuthHelper.GenerateServerToken(certManager); var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/pull-database"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Add("X-Server-Url", localUrl); + request.Headers.Add("X-Server-Token", sessionToken); var response = await httpClient.SendAsync(request); if (!response.IsSuccessStatusCode) @@ -483,7 +627,14 @@ public static class SyncEndpoints } } -public record RegisterPeerRequest(string PeerUrl); +public record RegisterPeerRequest( + string PeerUrl, + string EncryptedSecret, + string PublicKey, + string Signature +); + +public record HandshakeResponse(string SessionToken); public record DatabaseDumpPayload( List Users, @@ -498,3 +649,5 @@ public record SyncNodeState(string Uuid, DateTime UpdatedAt, bool IsDeleted); public record SyncExchangeRequest(List Items); public record SyncPushItem(Node Node, List AclEntries, List NodeKeys, string? BroadcastId = null); public record SyncExchangeResponse(List ToPush, List ToRequest); + +public record PingResponse(string PublicKey, string Timestamp, string Token); diff --git a/Server/Program.cs b/Server/Program.cs index 69327c9..9211875 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -28,6 +28,7 @@ public class Program // Register custom security managers & HTTP utilities builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -66,24 +67,9 @@ public class Program var hostApplicationLifetime = app.Services.GetRequiredService(); var services = app.Services; - // Dynamic Bootstrapping check on startup var config = app.Configuration; - var bootstrapUrl = config["BootstrapFromPeer"]; - if (!string.IsNullOrEmpty(bootstrapUrl)) - { - Task.Run(async () => - { - // Wait briefly for server startup - await Task.Delay(2000); - using var scope = services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var certManager = scope.ServiceProvider.GetRequiredService(); - var client = scope.ServiceProvider.GetRequiredService(); - await SyncEndpoints.BootstrapFromPeerServerAsync(bootstrapUrl, db, certManager, client); - }); - } - // Dynamic Handshake: Connect to target destination server and register local address on startup + // Dynamic Handshake & Bootstrapping: Connect to target destination server on startup, and optionally bootstrap var destUrl = config["Sync:DestinationServerUrl"]; var localUrl = config["Sync:LocalServerUrl"]; if (!string.IsNullOrEmpty(destUrl) && !string.IsNullOrEmpty(localUrl)) @@ -93,9 +79,61 @@ public class Program // Wait briefly for server startup await Task.Delay(2000); using var scope = services.CreateScope(); - var certManager = scope.ServiceProvider.GetRequiredService(); + var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); + var configuration = scope.ServiceProvider.GetRequiredService(); var client = scope.ServiceProvider.GetRequiredService(); - await SyncEndpoints.RegisterPeerWithDestinationAsync(destUrl, localUrl, certManager, client); + + // 1. Perform handshake + await SyncEndpoints.RegisterPeerWithDestinationAsync(destUrl, localUrl, rsaKeyManager, configuration, client); + + // 2. Check if we have successfully handshaked and obtained a token + if (!string.IsNullOrEmpty(SyncEndpoints.UpstreamSessionToken)) + { + var bootstrapMode = configuration["Sync:BootstrapFromUpstream"] ?? "Never"; + bool doBootstrap = false; + + if (string.Equals(bootstrapMode, "Always", StringComparison.OrdinalIgnoreCase)) + { + doBootstrap = true; + } + else if (string.Equals(bootstrapMode, "FirstTime", StringComparison.OrdinalIgnoreCase)) + { + var flagPath = Path.Combine(AppContext.BaseDirectory, "snote_bootstrapped.flag"); + if (!File.Exists(flagPath)) + { + doBootstrap = true; + } + else + { + Console.WriteLine("[Bootstrap] Skipped because BootstrapFromUpstream is set to FirstTime and server has bootstrapped before."); + } + } + + if (doBootstrap) + { + var db = scope.ServiceProvider.GetRequiredService(); + await SyncEndpoints.BootstrapFromPeerServerAsync(destUrl, localUrl, SyncEndpoints.UpstreamSessionToken, db, client); + + // If it's FirstTime, write the flag file + if (string.Equals(bootstrapMode, "FirstTime", StringComparison.OrdinalIgnoreCase)) + { + try + { + var flagPath = Path.Combine(AppContext.BaseDirectory, "snote_bootstrapped.flag"); + await File.WriteAllTextAsync(flagPath, DateTime.UtcNow.ToString("o")); + Console.WriteLine("[Bootstrap] Wrote bootstrap flag file."); + } + catch (Exception ex) + { + Console.WriteLine($"[Bootstrap] Warning: Could not write bootstrap flag file: {ex.Message}"); + } + } + } + } + else + { + Console.WriteLine("[Bootstrap] Skipped because handshake failed (no token obtained)."); + } }); } @@ -105,9 +143,9 @@ public class Program var token = hostApplicationLifetime.ApplicationStopping; using var scope = services.CreateScope(); var peerCache = scope.ServiceProvider.GetRequiredService(); - var certManager = scope.ServiceProvider.GetRequiredService(); + var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); var client = scope.ServiceProvider.GetRequiredService(); - await SyncEndpoints.StartHeartbeatPingerAsync(peerCache, certManager, client, token); + await SyncEndpoints.StartHeartbeatPingerAsync(peerCache, rsaKeyManager, client, token); }); app.Run(); diff --git a/Server/Security/AuthHelper.cs b/Server/Security/AuthHelper.cs index a346f43..7c2a1a2 100644 --- a/Server/Security/AuthHelper.cs +++ b/Server/Security/AuthHelper.cs @@ -45,6 +45,19 @@ public static class AuthHelper } } + public static bool IsServerTokenValid(HttpContext context, PeerCache peerCache) + { + var serverUrl = context.Request.Headers["X-Server-Url"].ToString(); + var serverToken = context.Request.Headers["X-Server-Token"].ToString(); + + if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(serverToken)) + { + return false; + } + + return peerCache.VerifySessionToken(serverUrl, serverToken); + } + // Helper to generate a server token for outgoing sync requests public static string GenerateServerToken(CertificateManager certificateManager) { diff --git a/Server/Security/PeerCache.cs b/Server/Security/PeerCache.cs index 4bcba04..57a6a52 100644 --- a/Server/Security/PeerCache.cs +++ b/Server/Security/PeerCache.cs @@ -6,16 +6,19 @@ namespace SNote.Server.Security; public class PeerCache { - private readonly ConcurrentDictionary _downstreamPeers = new(StringComparer.OrdinalIgnoreCase); + // Mapping: PeerUrl (normalized) -> PeerDetails (SessionToken, PublicKeyPem) + private readonly ConcurrentDictionary _downstreamPeers = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _recentBroadcastIds = new(StringComparer.OrdinalIgnoreCase); - public void RegisterPeer(string peerUrl) + public void RegisterPeer(string peerUrl, string sessionToken, string publicKeyPem) { - if (string.IsNullOrWhiteSpace(peerUrl)) return; + if (string.IsNullOrWhiteSpace(peerUrl) || string.IsNullOrWhiteSpace(sessionToken)) return; var cleanUrl = peerUrl.Trim().TrimEnd('/'); - _downstreamPeers.TryAdd(cleanUrl, 0); - Console.WriteLine($"[PeerCache] Registered downstream peer node: {cleanUrl}"); + var details = new PeerDetails(sessionToken, publicKeyPem.Trim()); + + _downstreamPeers[cleanUrl] = details; + Console.WriteLine($"[PeerCache] Registered downstream peer node: {cleanUrl} with secure session token."); } public void RemovePeer(string peerUrl) @@ -34,6 +37,40 @@ public class PeerCache return new List(_downstreamPeers.Keys); } + public string? GetToken(string peerUrl) + { + var cleanUrl = peerUrl.Trim().TrimEnd('/'); + return _downstreamPeers.TryGetValue(cleanUrl, out var details) ? details.SessionToken : null; + } + + public string? GetPublicKey(string peerUrl) + { + var cleanUrl = peerUrl.Trim().TrimEnd('/'); + return _downstreamPeers.TryGetValue(cleanUrl, out var details) ? details.PublicKeyPem : null; + } + + public bool VerifySessionToken(string peerUrl, string token) + { + if (string.IsNullOrEmpty(peerUrl) || string.IsNullOrEmpty(token)) return false; + + var cleanUrl = peerUrl.Trim().TrimEnd('/'); + if (_downstreamPeers.TryGetValue(cleanUrl, out var details)) + { + return string.Equals(details.SessionToken, token, StringComparison.Ordinal); + } + return false; + } + + public List<(string Url, string Token)> GetActivePeers() + { + var list = new List<(string Url, string Token)>(); + foreach (var kvp in _downstreamPeers) + { + list.Add((kvp.Key, kvp.Value.SessionToken)); + } + return list; + } + public bool TryProcessBroadcast(string broadcastId) { if (string.IsNullOrWhiteSpace(broadcastId)) return false; @@ -51,3 +88,5 @@ public class PeerCache return _recentBroadcastIds.TryAdd(broadcastId, DateTime.UtcNow); } } + +public record PeerDetails(string SessionToken, string PublicKeyPem); diff --git a/Server/Security/RsaKeyManager.cs b/Server/Security/RsaKeyManager.cs new file mode 100644 index 0000000..35852bc --- /dev/null +++ b/Server/Security/RsaKeyManager.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using Microsoft.Extensions.Configuration; + +namespace SNote.Server.Security; + +public class RsaKeyManager +{ + private readonly string _publicKeyPath; + private readonly string _privateKeyPath; + private RSA? _privateKeyRsa; + private RSA? _publicKeyRsa; + private string _publicKeyPem = string.Empty; + private string _privateKeyPem = string.Empty; + + public RsaKeyManager(IConfiguration configuration) + { + _publicKeyPath = Path.Combine(AppContext.BaseDirectory, "snote-public.pem"); + _privateKeyPath = Path.Combine(AppContext.BaseDirectory, "snote-private.pem"); + + var configPublicKey = configuration["Sync:PublicKey"]; + var configPrivateKey = configuration["Sync:PrivateKey"]; + + if (!string.IsNullOrEmpty(configPublicKey) && !string.IsNullOrEmpty(configPrivateKey)) + { + _publicKeyPem = configPublicKey; + _privateKeyPem = configPrivateKey; + InitializeKeys(); + } + else + { + LoadOrGenerateKeys(); + } + } + + private void InitializeKeys() + { + try + { + _publicKeyRsa = RSA.Create(); + _publicKeyRsa.ImportFromPem(_publicKeyPem); + + _privateKeyRsa = RSA.Create(); + _privateKeyRsa.ImportFromPem(_privateKeyPem); + } + catch (Exception ex) + { + Console.WriteLine($"[RsaKeyManager] Error initializing configured keys: {ex.Message}"); + throw; + } + } + + private void LoadOrGenerateKeys() + { + if (File.Exists(_privateKeyPath) && File.Exists(_publicKeyPath)) + { + try + { + _publicKeyPem = File.ReadAllText(_publicKeyPath); + _privateKeyPem = File.ReadAllText(_privateKeyPath); + InitializeKeys(); + Console.WriteLine("[RsaKeyManager] Loaded existing RSA keys from disk."); + return; + } + catch (Exception ex) + { + Console.WriteLine($"[RsaKeyManager] Error loading RSA keys from disk: {ex.Message}. Re-generating."); + } + } + + // Generate new keypair + Console.WriteLine("[RsaKeyManager] Generating new secure 2048-bit RSA keypair..."); + using var rsa = RSA.Create(2048); + + _publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem(); + _privateKeyPem = rsa.ExportPkcs8PrivateKeyPem(); + + File.WriteAllText(_publicKeyPath, _publicKeyPem); + File.WriteAllText(_privateKeyPath, _privateKeyPem); + + InitializeKeys(); + Console.WriteLine("[RsaKeyManager] Secure RSA keypair generated and saved to disk."); + } + + public RSA GetPrivateKey() + { + if (_privateKeyRsa == null) throw new InvalidOperationException("Private key is not loaded."); + return _privateKeyRsa; + } + + public RSA GetPublicKey() + { + if (_publicKeyRsa == null) throw new InvalidOperationException("Public key is not loaded."); + return _publicKeyRsa; + } + + public string GetPublicKeyPem() + { + return _publicKeyPem; + } + + public string GetPrivateKeyPem() + { + return _privateKeyPem; + } +} diff --git a/Server/appsettings.json b/Server/appsettings.json index fd3a7d4..ebf1f76 100644 --- a/Server/appsettings.json +++ b/Server/appsettings.json @@ -8,7 +8,8 @@ "AllowedHosts": "*", "Sync": { "DestinationServerUrl": "", - "LocalServerUrl": "" + "LocalServerUrl": "", + "BootstrapFromUpstream": "FirstTime" }, "EnableFullDbPull": false }