Updated with Antigravity.

This commit is contained in:
Creeper Lv
2026-06-01 05:49:08 +10:00
parent e8ab8e0684
commit b263291dc2
7 changed files with 436 additions and 82 deletions
+9 -6
View File
@@ -184,14 +184,15 @@ public static class NodeEndpoints
db.NodeKeys.Add(nodeKey); db.NodeKeys.Add(nodeKey);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Broadcast change to other servers in background
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
using var scope = context.RequestServices.CreateScope(); using var scope = context.RequestServices.CreateScope();
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>(); var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, httpClient); var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -261,14 +262,15 @@ public static class NodeEndpoints
node.UpdatedAt = DateTime.UtcNow; node.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Broadcast change to other servers in background
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
using var scope = context.RequestServices.CreateScope(); using var scope = context.RequestServices.CreateScope();
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>(); var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, httpClient); var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -300,14 +302,15 @@ public static class NodeEndpoints
node.UpdatedAt = DateTime.UtcNow; node.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Broadcast change to other servers in background
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
using var scope = context.RequestServices.CreateScope(); using var scope = context.RequestServices.CreateScope();
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>(); var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, httpClient); var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient);
} }
catch (Exception ex) catch (Exception ex)
{ {
+201 -48
View File
@@ -21,31 +21,80 @@ namespace SNote.Server.Endpoints;
public static class SyncEndpoints public static class SyncEndpoints
{ {
public static string? UpstreamSessionToken { get; set; } = null;
public static void MapSyncEndpoints(this IEndpointRouteBuilder routes) public static void MapSyncEndpoints(this IEndpointRouteBuilder routes)
{ {
// 0. Heartbeat ping endpoint // 0. Heartbeat ping endpoint: downstream returns its public key, current timestamp, and a signature (token) of the timestamp
routes.MapGet("/api/sync/ping", () => Results.Ok(new { status = "Healthy" })); routes.MapGet("/api/sync/ping", (RsaKeyManager rsaKeyManager) =>
{
try
{
var timestamp = DateTime.UtcNow.ToString("o");
var dataToSign = System.Text.Encoding.UTF8.GetBytes(timestamp);
// 1. Handshake Endpoint: Receive registration of downstream peers var privateRsa = rsaKeyManager.GetPrivateKey();
routes.MapPost("/api/sync/register-peer", async (RegisterPeerRequest req, HttpContext context, PeerCache peerCache, CertificateManager certManager) => var signatureBytes = privateRsa.SignData(dataToSign, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1);
var token = Convert.ToBase64String(signatureBytes);
return Results.Ok(new
{ {
if (string.IsNullOrWhiteSpace(req.PeerUrl)) publicKey = rsaKeyManager.GetPublicKeyPem(),
timestamp = timestamp,
token = token
});
}
catch (Exception ex)
{ {
return Results.BadRequest("Peer URL is required."); 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 try
if (!AuthHelper.IsServerAuthenticated(context, certManager))
{ {
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);
// 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);
} }
peerCache.RegisterPeer(req.PeerUrl); // 3. Issue secure session token and register peer details
return Results.Ok(new { message = "Server peer registered successfully." }); 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) // 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<bool>("EnableFullDbPull", false); var enablePull = config.GetValue<bool>("EnableFullDbPull", false);
if (!enablePull) 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); 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(); var users = await db.Users.ToListAsync();
@@ -70,9 +119,9 @@ public static class SyncEndpoints
}); });
// 3. Synchronize nodes states // 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(); return Results.Unauthorized();
} }
@@ -120,9 +169,9 @@ public static class SyncEndpoints
}); });
// 4. Receive full node pushes during sync // 4. Receive full node pushes during sync
routes.MapPost("/api/sync/push", async (List<SyncPushItem> items, HttpContext context, ServerDbContext db, CertificateManager certManager) => routes.MapPost("/api/sync/push", async (List<SyncPushItem> items, HttpContext context, ServerDbContext db, PeerCache peerCache) =>
{ {
if (!AuthHelper.IsServerAuthenticated(context, certManager)) if (!AuthHelper.IsServerTokenValid(context, peerCache))
{ {
return Results.Unauthorized(); return Results.Unauthorized();
} }
@@ -160,9 +209,9 @@ public static class SyncEndpoints
}); });
// 5. Receive broadcast of changes (broadcasts single node updates) // 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(); return Results.Unauthorized();
} }
@@ -216,7 +265,8 @@ public static class SyncEndpoints
{ {
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
await BroadcastNodeChangeAsync(item.Node.Uuid, db, peerCache, certManager, httpClient, broadcastId); var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
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 // Handshake: Tells the destination server who we are and registers us using RSA asymmetric verification
public static async Task RegisterPeerWithDestinationAsync(string destinationUrl, string localUrl, CertificateManager certManager, HttpClient httpClient) public static async Task RegisterPeerWithDestinationAsync(string destinationUrl, string localUrl, RsaKeyManager rsaKeyManager, IConfiguration configuration, HttpClient httpClient)
{ {
try 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 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 json = JsonSerializer.Serialize(payload);
var request = new HttpRequestMessage(HttpMethod.Post, cleanDest) 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); var response = await httpClient.SendAsync(request);
if (response.IsSuccessStatusCode) 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<HandshakeResponse>(responseContent, options);
if (responseData != null && !string.IsNullOrEmpty(responseData.SessionToken))
{
UpstreamSessionToken = responseData.SessionToken;
Console.WriteLine($"[Handshake] Successfully handshaked and obtained Session Token: {UpstreamSessionToken}");
} }
else else
{ {
Console.WriteLine($"[Handshake] Handshake failed: {response.StatusCode}"); Console.WriteLine("[Handshake] Handshake warning: Handshake returned success but no session token was found.");
}
}
else
{
Console.WriteLine($"[Handshake] Handshake failed with status: {response.StatusCode}");
} }
} }
catch (Exception ex) 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 // 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, CertificateManager certManager, HttpClient httpClient, string? broadcastId = null) 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); var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == nodeUuid);
if (node == null) return; if (node == null) return;
@@ -277,24 +362,45 @@ public static class SyncEndpoints
var payload = new SyncPushItem(node, acls, keys, bId); var payload = new SyncPushItem(node, acls, keys, bId);
var json = JsonSerializer.Serialize(payload); 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(); 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(); var dynamicPeers = peerCache.GetPeers();
// Combine peers // Combine peers
var allPeers = dbPeers.Union(dynamicPeers, StringComparer.OrdinalIgnoreCase).ToList(); var allPeers = dbPeers.Union(dynamicPeers, StringComparer.OrdinalIgnoreCase).ToList();
var localUrl = configuration["Sync:LocalServerUrl"] ?? "";
var destUrl = configuration["Sync:DestinationServerUrl"] ?? "";
foreach (var peerUrl in allPeers) foreach (var peerUrl in allPeers)
{ {
try try
{ {
var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/broadcast"); 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); // Attach custom headers for secure authentication
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); 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); var response = await httpClient.SendAsync(request);
Console.WriteLine($"Broadcast to {peerUrl}: {response.StatusCode}"); 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 // Periodically pings downstream peer servers in PeerCache to ensure they are online and authentic
public static async Task StartHeartbeatPingerAsync(PeerCache peerCache, CertificateManager certManager, HttpClient httpClient, System.Threading.CancellationToken token) public static async Task StartHeartbeatPingerAsync(PeerCache peerCache, RsaKeyManager rsaKeyManager, HttpClient httpClient, System.Threading.CancellationToken token)
{ {
Console.WriteLine("[Heartbeat] Starting periodic downstream heartbeat pinger..."); Console.WriteLine("[Heartbeat] Starting periodic downstream heartbeat pinger...");
while (!token.IsCancellationRequested) while (!token.IsCancellationRequested)
@@ -320,14 +426,11 @@ public static class SyncEndpoints
var peers = peerCache.GetPeers(); var peers = peerCache.GetPeers();
if (peers.Count == 0) continue; if (peers.Count == 0) continue;
var jwtToken = AuthHelper.GenerateServerToken(certManager);
foreach (var peerUrl in peers) foreach (var peerUrl in peers)
{ {
try try
{ {
var request = new HttpRequestMessage(HttpMethod.Get, $"{peerUrl.TrimEnd('/')}/api/sync/ping"); 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 // Set a short timeout (e.g. 5 seconds) to avoid blocking
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(5)); 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..."); Console.WriteLine($"[Heartbeat] Peer {peerUrl} ping failed with status code: {response.StatusCode}. Evicting...");
peerCache.RemovePeer(peerUrl); peerCache.RemovePeer(peerUrl);
continue;
} }
var responseContent = await response.Content.ReadAsStringAsync();
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var pingData = JsonSerializer.Deserialize<PingResponse>(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) 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 try
{ {
Console.WriteLine($"Performing full database pulling bootstrap from {peerUrl}..."); 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"); 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); var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode) 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( public record DatabaseDumpPayload(
List<User> Users, List<User> Users,
@@ -498,3 +649,5 @@ public record SyncNodeState(string Uuid, DateTime UpdatedAt, bool IsDeleted);
public record SyncExchangeRequest(List<SyncNodeState> Items); public record SyncExchangeRequest(List<SyncNodeState> Items);
public record SyncPushItem(Node Node, List<AclEntry> AclEntries, List<NodeKey> NodeKeys, string? BroadcastId = null); public record SyncPushItem(Node Node, List<AclEntry> AclEntries, List<NodeKey> NodeKeys, string? BroadcastId = null);
public record SyncExchangeResponse(List<SyncPushItem> ToPush, List<string> ToRequest); public record SyncExchangeResponse(List<SyncPushItem> ToPush, List<string> ToRequest);
public record PingResponse(string PublicKey, string Timestamp, string Token);
+58 -20
View File
@@ -28,6 +28,7 @@ public class Program
// Register custom security managers & HTTP utilities // Register custom security managers & HTTP utilities
builder.Services.AddSingleton<CertificateManager>(); builder.Services.AddSingleton<CertificateManager>();
builder.Services.AddSingleton<RsaKeyManager>();
builder.Services.AddSingleton<PeerCache>(); builder.Services.AddSingleton<PeerCache>();
builder.Services.AddSingleton<HttpClient>(); builder.Services.AddSingleton<HttpClient>();
@@ -66,24 +67,9 @@ public class Program
var hostApplicationLifetime = app.Services.GetRequiredService<IHostApplicationLifetime>(); var hostApplicationLifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
var services = app.Services; var services = app.Services;
// Dynamic Bootstrapping check on startup
var config = app.Configuration; 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<ServerDbContext>();
var certManager = scope.ServiceProvider.GetRequiredService<CertificateManager>();
var client = scope.ServiceProvider.GetRequiredService<HttpClient>();
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 destUrl = config["Sync:DestinationServerUrl"];
var localUrl = config["Sync:LocalServerUrl"]; var localUrl = config["Sync:LocalServerUrl"];
if (!string.IsNullOrEmpty(destUrl) && !string.IsNullOrEmpty(localUrl)) if (!string.IsNullOrEmpty(destUrl) && !string.IsNullOrEmpty(localUrl))
@@ -93,9 +79,61 @@ public class Program
// Wait briefly for server startup // Wait briefly for server startup
await Task.Delay(2000); await Task.Delay(2000);
using var scope = services.CreateScope(); using var scope = services.CreateScope();
var certManager = scope.ServiceProvider.GetRequiredService<CertificateManager>(); var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var client = scope.ServiceProvider.GetRequiredService<HttpClient>(); var client = scope.ServiceProvider.GetRequiredService<HttpClient>();
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<ServerDbContext>();
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; var token = hostApplicationLifetime.ApplicationStopping;
using var scope = services.CreateScope(); using var scope = services.CreateScope();
var peerCache = scope.ServiceProvider.GetRequiredService<PeerCache>(); var peerCache = scope.ServiceProvider.GetRequiredService<PeerCache>();
var certManager = scope.ServiceProvider.GetRequiredService<CertificateManager>(); var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
var client = scope.ServiceProvider.GetRequiredService<HttpClient>(); var client = scope.ServiceProvider.GetRequiredService<HttpClient>();
await SyncEndpoints.StartHeartbeatPingerAsync(peerCache, certManager, client, token); await SyncEndpoints.StartHeartbeatPingerAsync(peerCache, rsaKeyManager, client, token);
}); });
app.Run(); app.Run();
+13
View File
@@ -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 // Helper to generate a server token for outgoing sync requests
public static string GenerateServerToken(CertificateManager certificateManager) public static string GenerateServerToken(CertificateManager certificateManager)
{ {
+44 -5
View File
@@ -6,16 +6,19 @@ namespace SNote.Server.Security;
public class PeerCache public class PeerCache
{ {
private readonly ConcurrentDictionary<string, byte> _downstreamPeers = new(StringComparer.OrdinalIgnoreCase); // Mapping: PeerUrl (normalized) -> PeerDetails (SessionToken, PublicKeyPem)
private readonly ConcurrentDictionary<string, PeerDetails> _downstreamPeers = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, DateTime> _recentBroadcastIds = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, DateTime> _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('/'); var cleanUrl = peerUrl.Trim().TrimEnd('/');
_downstreamPeers.TryAdd(cleanUrl, 0); var details = new PeerDetails(sessionToken, publicKeyPem.Trim());
Console.WriteLine($"[PeerCache] Registered downstream peer node: {cleanUrl}");
_downstreamPeers[cleanUrl] = details;
Console.WriteLine($"[PeerCache] Registered downstream peer node: {cleanUrl} with secure session token.");
} }
public void RemovePeer(string peerUrl) public void RemovePeer(string peerUrl)
@@ -34,6 +37,40 @@ public class PeerCache
return new List<string>(_downstreamPeers.Keys); return new List<string>(_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) public bool TryProcessBroadcast(string broadcastId)
{ {
if (string.IsNullOrWhiteSpace(broadcastId)) return false; if (string.IsNullOrWhiteSpace(broadcastId)) return false;
@@ -51,3 +88,5 @@ public class PeerCache
return _recentBroadcastIds.TryAdd(broadcastId, DateTime.UtcNow); return _recentBroadcastIds.TryAdd(broadcastId, DateTime.UtcNow);
} }
} }
public record PeerDetails(string SessionToken, string PublicKeyPem);
+107
View File
@@ -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;
}
}
+2 -1
View File
@@ -8,7 +8,8 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"Sync": { "Sync": {
"DestinationServerUrl": "", "DestinationServerUrl": "",
"LocalServerUrl": "" "LocalServerUrl": "",
"BootstrapFromUpstream": "FirstTime"
}, },
"EnableFullDbPull": false "EnableFullDbPull": false
} }