diff --git a/Server/Data/ServerDbContext.cs b/Server/Data/ServerDbContext.cs index 64ff6eb..1d4cb57 100644 --- a/Server/Data/ServerDbContext.cs +++ b/Server/Data/ServerDbContext.cs @@ -14,7 +14,6 @@ public class ServerDbContext : DbContext public DbSet NodeKeys => Set(); public DbSet PendingShares => Set(); public DbSet AclEntries => Set(); - public DbSet PeerServers => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/Server/Endpoints/AuthEndpoints.cs b/Server/Endpoints/AuthEndpoints.cs index 32e4cc2..2a1efa4 100644 --- a/Server/Endpoints/AuthEndpoints.cs +++ b/Server/Endpoints/AuthEndpoints.cs @@ -18,7 +18,7 @@ public static class AuthEndpoints { var group = routes.MapGroup("/api/auth"); - group.MapPost("/register", async (RegisterRequest req, ServerDbContext db) => + group.MapPost("/register", async (RegisterRequest req, HttpContext context, ServerDbContext db, PeerCache peerCache, RsaKeyManager rsaKeyManager, IConfiguration configuration, HttpClient httpClient) => { if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password) || string.IsNullOrWhiteSpace(req.EncryptedKey)) { @@ -41,9 +41,66 @@ public static class AuthEndpoints db.Users.Add(user); await db.SaveChangesAsync(); + _ = Task.Run(async () => + { + try + { + using var scope = context.RequestServices.CreateScope(); + var scopeDb = scope.ServiceProvider.GetRequiredService(); + await SyncEndpoints.BroadcastUserChangeAsync(user.Username, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); + } + catch (Exception ex) + { + Console.WriteLine($"Error broadcasting user registration: {ex.Message}"); + } + }); + return Results.Ok(new { message = "Registration successful." }); }); + group.MapPost("/change-password", async (ChangePasswordRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, RsaKeyManager rsaKeyManager, IConfiguration configuration, HttpClient httpClient) => + { + var username = AuthHelper.GetAuthenticatedUser(context, certManager); + if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); + + if (string.IsNullOrWhiteSpace(req.OldPassword) || string.IsNullOrWhiteSpace(req.NewPassword) || string.IsNullOrWhiteSpace(req.NewEncryptedKey)) + { + return Results.BadRequest("Old password, new password, and new encrypted key are required."); + } + + var user = await db.Users.FirstOrDefaultAsync(u => u.Username.ToLower() == username.ToLower()); + if (user == null) return Results.NotFound("User not found."); + + // Verify old password + if (!PasswordHasher.VerifyPassword(user.PasswordHash, req.OldPassword)) + { + return Results.BadRequest("Incorrect old password."); + } + + // Update user properties + user.PasswordHash = PasswordHasher.HashPassword(req.NewPassword); + user.EncryptedKey = req.NewEncryptedKey; + + await db.SaveChangesAsync(); + + // Broadcast the user password change! + _ = Task.Run(async () => + { + try + { + using var scope = context.RequestServices.CreateScope(); + var scopeDb = scope.ServiceProvider.GetRequiredService(); + await SyncEndpoints.BroadcastUserChangeAsync(user.Username, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); + } + catch (Exception ex) + { + Console.WriteLine($"Error broadcasting password change: {ex.Message}"); + } + }); + + return Results.Ok(new { message = "Password changed successfully." }); + }); + group.MapPost("/login", async (LoginRequest req, ServerDbContext db, CertificateManager certManager) => { if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) @@ -97,3 +154,4 @@ public static class AuthEndpoints public record RegisterRequest(string Username, string Password, string EncryptedKey); public record LoginRequest(string Username, string Password); public record LoginResponse(string Token, string EncryptedKey); +public record ChangePasswordRequest(string OldPassword, string NewPassword, string NewEncryptedKey); diff --git a/Server/Endpoints/NodeEndpoints.cs b/Server/Endpoints/NodeEndpoints.cs index d460d19..7daf8c5 100644 --- a/Server/Endpoints/NodeEndpoints.cs +++ b/Server/Endpoints/NodeEndpoints.cs @@ -338,7 +338,7 @@ public static class NodeEndpoints }); // PUT update ACL entries - group.MapPut("/{uuid}/acl", async (string uuid, List aclReqs, HttpContext context, ServerDbContext db, CertificateManager certManager) => + group.MapPut("/{uuid}/acl", async (string uuid, List aclReqs, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); @@ -431,11 +431,27 @@ public static class NodeEndpoints node.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); + _ = Task.Run(async () => + { + try + { + using var scope = context.RequestServices.CreateScope(); + var scopeDb = scope.ServiceProvider.GetRequiredService(); + var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); + var configuration = scope.ServiceProvider.GetRequiredService(); + await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); + } + catch (Exception ex) + { + Console.WriteLine($"Error broadcasting ACL change: {ex.Message}"); + } + }); + return Results.Ok(new { message = "ACLs updated successfully." }); }); // POST Share a Node (generates k0 encrypted by k1 and saves in PendingShare) - group.MapPost("/share", async (ShareNodeRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager) => + group.MapPost("/share", async (ShareNodeRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); @@ -499,13 +515,30 @@ public static class NodeEndpoints }); } + node.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); + _ = Task.Run(async () => + { + try + { + using var scope = context.RequestServices.CreateScope(); + var scopeDb = scope.ServiceProvider.GetRequiredService(); + var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); + var configuration = scope.ServiceProvider.GetRequiredService(); + await SyncEndpoints.BroadcastNodeChangeAsync(req.NodeUuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); + } + catch (Exception ex) + { + Console.WriteLine($"Error broadcasting share change: {ex.Message}"); + } + }); + return Results.Ok(new { message = "Share initiated successfully.", guid = req.NodeUuid }); }); // POST Accept a Share (decrypts E_k1(k0) using k1 and re-encrypts using recipient's masterKey) - group.MapPost("/accept-share", async (AcceptShareRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager) => + group.MapPost("/accept-share", async (AcceptShareRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); @@ -563,8 +596,31 @@ public static class NodeEndpoints // Delete pending share db.PendingShares.Remove(pendingShare); + // Fetch the corresponding node and update UpdatedAt timestamp + var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == req.NodeUuid && !n.IsDeleted); + if (node != null) + { + node.UpdatedAt = DateTime.UtcNow; + } + await db.SaveChangesAsync(); + _ = Task.Run(async () => + { + try + { + using var scope = context.RequestServices.CreateScope(); + var scopeDb = scope.ServiceProvider.GetRequiredService(); + var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); + var configuration = scope.ServiceProvider.GetRequiredService(); + await SyncEndpoints.BroadcastNodeChangeAsync(req.NodeUuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); + } + catch (Exception ex) + { + Console.WriteLine($"Error broadcasting accept-share change: {ex.Message}"); + } + }); + return Results.Ok(new { message = "Share accepted and linked successfully." }); }); } diff --git a/Server/Endpoints/SyncEndpoints.cs b/Server/Endpoints/SyncEndpoints.cs index 9aa4c3a..883c9c8 100644 --- a/Server/Endpoints/SyncEndpoints.cs +++ b/Server/Endpoints/SyncEndpoints.cs @@ -112,9 +112,8 @@ public static class SyncEndpoints var nodeKeys = await db.NodeKeys.ToListAsync(); var pendingShares = await db.PendingShares.ToListAsync(); var aclEntries = await db.AclEntries.ToListAsync(); - var peerServers = await db.PeerServers.ToListAsync(); - var dump = new DatabaseDumpPayload(users, nodes, nodeKeys, pendingShares, aclEntries, peerServers); + var dump = new DatabaseDumpPayload(users, nodes, nodeKeys, pendingShares, aclEntries); return Results.Ok(dump); }); @@ -234,6 +233,10 @@ public static class SyncEndpoints db.Nodes.Add(item.Node); db.AclEntries.AddRange(item.AclEntries); db.NodeKeys.AddRange(item.NodeKeys); + if (item.PendingShares != null) + { + db.PendingShares.AddRange(item.PendingShares); + } updated = true; } else if (localNode.UpdatedAt < item.Node.UpdatedAt) @@ -253,6 +256,13 @@ public static class SyncEndpoints db.NodeKeys.RemoveRange(existingKeys); db.NodeKeys.AddRange(item.NodeKeys); + var existingShares = await db.PendingShares.Where(s => s.NodeUuid == item.Node.Uuid).ToListAsync(); + db.PendingShares.RemoveRange(existingShares); + if (item.PendingShares != null) + { + db.PendingShares.AddRange(item.PendingShares); + } + updated = true; } @@ -273,6 +283,59 @@ public static class SyncEndpoints return Results.Ok(new { message = "Broadcast processed.", updated }); }); + + // Receive broadcast of user registration or password updates + routes.MapPost("/api/sync/broadcast-user", async (UserBroadcastPayload payload, HttpContext context, ServerDbContext db, PeerCache peerCache, RsaKeyManager rsaKeyManager, HttpClient httpClient) => + { + if (!AuthHelper.IsServerTokenValid(context, peerCache)) + { + return Results.Unauthorized(); + } + + var broadcastId = payload.BroadcastId; + if (!string.IsNullOrEmpty(broadcastId)) + { + if (!peerCache.TryProcessBroadcast(broadcastId)) + { + return Results.Ok(new { message = "User broadcast already processed (loop detected).", updated = false }); + } + } + + var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Username.ToLower() == payload.User.Username.ToLower()); + bool updated = false; + + if (existingUser == null) + { + db.Users.Add(payload.User); + updated = true; + } + else + { + if (existingUser.PasswordHash != payload.User.PasswordHash || existingUser.EncryptedKey != payload.User.EncryptedKey) + { + existingUser.PasswordHash = payload.User.PasswordHash; + existingUser.EncryptedKey = payload.User.EncryptedKey; + updated = true; + } + } + + if (updated) + { + await db.SaveChangesAsync(); + + // Relay the user broadcast to other peers + if (!string.IsNullOrEmpty(broadcastId)) + { + _ = Task.Run(async () => + { + var configuration = context.RequestServices.GetRequiredService(); + await BroadcastUserChangeAsync(payload.User.Username, db, peerCache, rsaKeyManager, configuration, httpClient, broadcastId); + }); + } + } + + return Results.Ok(new { message = "User broadcast processed.", updated }); + }); } // Handshake: Tells the destination server who we are and registers us using RSA asymmetric verification @@ -353,32 +416,33 @@ public static class SyncEndpoints var acls = await db.AclEntries.Where(a => a.NodeUuid == nodeUuid).ToListAsync(); var keys = await db.NodeKeys.Where(k => k.NodeUuid == nodeUuid).ToListAsync(); + var pendingShares = await db.PendingShares.Where(s => s.NodeUuid == nodeUuid).ToListAsync(); var bId = string.IsNullOrEmpty(broadcastId) ? Guid.NewGuid().ToString() : broadcastId; // Register it locally in PeerCache so we don't process it ourselves if it loops back peerCache.TryProcessBroadcast(bId); - var payload = new SyncPushItem(node, acls, keys, bId); + var payload = new SyncPushItem(node, acls, keys, bId, pendingShares); var json = JsonSerializer.Serialize(payload); - // Fetch database configured peers (upstream destination) - var dbPeers = await db.PeerServers.Select(p => p.Url).ToListAsync(); - // 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 allPeers = peerCache.GetPeers(); - var localUrl = configuration["Sync:LocalServerUrl"] ?? ""; - var destUrl = configuration["Sync:DestinationServerUrl"] ?? ""; + var localUrl = (configuration["Sync:LocalServerUrl"] ?? "").Trim().TrimEnd('/'); + var destUrl = (configuration["Sync:DestinationServerUrl"] ?? "").Trim().TrimEnd('/'); - foreach (var peerUrl in allPeers) + if (!string.IsNullOrEmpty(destUrl) && !allPeers.Contains(destUrl, StringComparer.OrdinalIgnoreCase)) { + allPeers.Add(destUrl); + } + + foreach (var rawPeerUrl in allPeers) + { + var peerUrl = rawPeerUrl.Trim().TrimEnd('/'); try { - var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/broadcast"); + var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl}/api/sync/broadcast"); request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); // Attach custom headers for secure authentication @@ -412,6 +476,67 @@ public static class SyncEndpoints } } + public static async Task BroadcastUserChangeAsync(string username, ServerDbContext db, PeerCache peerCache, RsaKeyManager rsaKeyManager, IConfiguration configuration, HttpClient httpClient, string? broadcastId = null) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.Username.ToLower() == username.ToLower()); + if (user == null) return; + + var bId = string.IsNullOrEmpty(broadcastId) ? Guid.NewGuid().ToString() : broadcastId; + + // Register it locally in PeerCache so we don't process it ourselves if it loops back + peerCache.TryProcessBroadcast(bId); + + var payload = new UserBroadcastPayload(user, bId); + var json = JsonSerializer.Serialize(payload); + + // Fetch dynamically handshake-registered in-memory peers (downstream child nodes) + var allPeers = peerCache.GetPeers(); + + var localUrl = (configuration["Sync:LocalServerUrl"] ?? "").Trim().TrimEnd('/'); + var destUrl = (configuration["Sync:DestinationServerUrl"] ?? "").Trim().TrimEnd('/'); + + if (!string.IsNullOrEmpty(destUrl) && !allPeers.Contains(destUrl, StringComparer.OrdinalIgnoreCase)) + { + allPeers.Add(destUrl); + } + + foreach (var rawPeerUrl in allPeers) + { + var peerUrl = rawPeerUrl.Trim().TrimEnd('/'); + try + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl}/api/sync/broadcast-user"); + request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + // Attach custom headers for secure authentication + request.Headers.Add("X-Server-Url", localUrl); + + if (string.Equals(peerUrl, destUrl, StringComparison.OrdinalIgnoreCase)) + { + if (!string.IsNullOrEmpty(UpstreamSessionToken)) + { + request.Headers.Add("X-Server-Token", UpstreamSessionToken); + } + } + else + { + var token = peerCache.GetToken(peerUrl); + if (!string.IsNullOrEmpty(token)) + { + request.Headers.Add("X-Server-Token", token); + } + } + + var response = await httpClient.SendAsync(request); + Console.WriteLine($"User broadcast to {peerUrl}: {response.StatusCode}"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to broadcast user change to {peerUrl}: {ex.Message}"); + } + } + } + // 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) { @@ -607,7 +732,6 @@ public static class SyncEndpoints db.NodeKeys.RemoveRange(await db.NodeKeys.ToListAsync()); db.PendingShares.RemoveRange(await db.PendingShares.ToListAsync()); db.AclEntries.RemoveRange(await db.AclEntries.ToListAsync()); - db.PeerServers.RemoveRange(await db.PeerServers.ToListAsync()); await db.SaveChangesAsync(); db.Users.AddRange(dump.Users); @@ -615,7 +739,6 @@ public static class SyncEndpoints db.NodeKeys.AddRange(dump.NodeKeys); db.PendingShares.AddRange(dump.PendingShares); db.AclEntries.AddRange(dump.AclEntries); - db.PeerServers.AddRange(dump.PeerServers); await db.SaveChangesAsync(); Console.WriteLine("Full database pull bootstrap completed successfully!"); @@ -641,13 +764,14 @@ public record DatabaseDumpPayload( List Nodes, List NodeKeys, List PendingShares, - List AclEntries, - List PeerServers + List AclEntries ); 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 SyncPushItem(Node Node, List AclEntries, List NodeKeys, string? BroadcastId = null, List? PendingShares = null); public record SyncExchangeResponse(List ToPush, List ToRequest); public record PingResponse(string PublicKey, string Timestamp, string Token); + +public record UserBroadcastPayload(User User, string? BroadcastId = null);