Updated with Antigravity.
This commit is contained in:
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"Sync": {
|
"Sync": {
|
||||||
"DestinationServerUrl": "",
|
"DestinationServerUrl": "",
|
||||||
"LocalServerUrl": ""
|
"LocalServerUrl": "",
|
||||||
|
"BootstrapFromUpstream": "FirstTime"
|
||||||
},
|
},
|
||||||
"EnableFullDbPull": false
|
"EnableFullDbPull": false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user