2026-06-01 05:09:20 +10:00
|
|
|
using System;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using Microsoft.AspNetCore.Builder;
|
|
|
|
|
using Microsoft.AspNetCore.Http;
|
|
|
|
|
using Microsoft.AspNetCore.Routing;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using SNote.Models;
|
|
|
|
|
using SNote.Server.Data;
|
|
|
|
|
using SNote.Server.Security;
|
|
|
|
|
|
|
|
|
|
namespace SNote.Server.Endpoints;
|
|
|
|
|
|
|
|
|
|
public static class NodeEndpoints
|
|
|
|
|
{
|
|
|
|
|
public static void MapNodeEndpoints(this IEndpointRouteBuilder routes)
|
|
|
|
|
{
|
|
|
|
|
var group = routes.MapGroup("/api/nodes");
|
|
|
|
|
|
|
|
|
|
// Helper to check user access and fetch decrypted content
|
|
|
|
|
async Task<NodeDto?> GetNodeWithAccessAsync(string uuid, string username, string? masterKey, ServerDbContext db)
|
|
|
|
|
{
|
|
|
|
|
var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid && !n.IsDeleted);
|
|
|
|
|
if (node == null) return null;
|
|
|
|
|
|
|
|
|
|
// Check if user is owner
|
|
|
|
|
bool isOwner = node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
|
|
|
|
// Fetch ACL entries
|
|
|
|
|
var acls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync();
|
|
|
|
|
var userAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals(username, StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
var everyoneAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals("everyone", StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
|
|
|
|
|
bool canView = isOwner || (userAcl?.CanView ?? false) || (everyoneAcl?.CanView ?? false);
|
|
|
|
|
if (!canView) return null;
|
|
|
|
|
|
|
|
|
|
bool canEdit = isOwner || (userAcl?.CanEdit ?? false) || (everyoneAcl?.CanEdit ?? false);
|
|
|
|
|
bool canDelete = isOwner || (userAcl?.CanDelete ?? false) || (everyoneAcl?.CanDelete ?? false);
|
|
|
|
|
bool canRename = isOwner || (userAcl?.CanRename ?? false) || (everyoneAcl?.CanRename ?? false);
|
|
|
|
|
|
|
|
|
|
// Determine if encrypted
|
|
|
|
|
bool isPublic = everyoneAcl?.CanView ?? false;
|
|
|
|
|
string plaintext = node.Content;
|
|
|
|
|
|
|
|
|
|
if (!isPublic && !string.IsNullOrEmpty(node.Content))
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(masterKey))
|
|
|
|
|
{
|
|
|
|
|
// No key provided; return encrypted content or a placeholder
|
|
|
|
|
plaintext = "[Encrypted Content - Provide Encryption Key]";
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
// Fetch the encrypted node key for this user
|
|
|
|
|
var nodeKey = await db.NodeKeys.FirstOrDefaultAsync(k => k.NodeUuid == uuid && k.Username.ToLower() == username.ToLower());
|
|
|
|
|
if (nodeKey != null)
|
|
|
|
|
{
|
|
|
|
|
// Decrypt the node key (k_0) using the user's master key
|
|
|
|
|
string rawNodeKey = SecurityHelper.Decrypt(nodeKey.EncryptedNodeKey, masterKey);
|
|
|
|
|
// Decrypt the node content using the raw node key (k_0)
|
|
|
|
|
plaintext = SecurityHelper.Decrypt(node.Content, rawNodeKey);
|
|
|
|
|
}
|
|
|
|
|
else if (isOwner)
|
|
|
|
|
{
|
|
|
|
|
// Owner but no node key? This shouldn't happen, but fallback
|
|
|
|
|
plaintext = "[Encrypted Content - Key Missing]";
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
plaintext = "[Encrypted Content - Access Denied]";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new NodeDto(
|
|
|
|
|
node.Uuid,
|
|
|
|
|
node.Name,
|
|
|
|
|
node.Type,
|
|
|
|
|
plaintext,
|
|
|
|
|
node.OwnerUsername,
|
|
|
|
|
node.UpdatedAt,
|
|
|
|
|
isOwner,
|
|
|
|
|
canView,
|
|
|
|
|
canEdit,
|
|
|
|
|
canDelete,
|
|
|
|
|
canRename,
|
|
|
|
|
isPublic
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GET all accessible nodes
|
|
|
|
|
group.MapGet("/", async (HttpContext context, ServerDbContext db, CertificateManager certManager) =>
|
|
|
|
|
{
|
|
|
|
|
var username = AuthHelper.GetAuthenticatedUser(context, certManager);
|
|
|
|
|
if (string.IsNullOrEmpty(username)) return Results.Unauthorized();
|
|
|
|
|
|
|
|
|
|
var masterKey = context.Request.Headers["X-Encryption-Key"].ToString();
|
|
|
|
|
|
|
|
|
|
// Fetch nodes where owner is user, or user/everyone has ACL access
|
|
|
|
|
var allNodes = await db.Nodes.Where(n => !n.IsDeleted).ToListAsync();
|
|
|
|
|
var result = new List<NodeDto>();
|
|
|
|
|
|
|
|
|
|
foreach (var node in allNodes)
|
|
|
|
|
{
|
|
|
|
|
var dto = await GetNodeWithAccessAsync(node.Uuid, username, masterKey, db);
|
|
|
|
|
if (dto != null)
|
|
|
|
|
{
|
|
|
|
|
result.Add(dto);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Results.Ok(result);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET single node by UUID
|
|
|
|
|
group.MapGet("/{uuid}", async (string uuid, HttpContext context, ServerDbContext db, CertificateManager certManager) =>
|
|
|
|
|
{
|
|
|
|
|
var username = AuthHelper.GetAuthenticatedUser(context, certManager);
|
|
|
|
|
if (string.IsNullOrEmpty(username)) return Results.Unauthorized();
|
|
|
|
|
|
|
|
|
|
var masterKey = context.Request.Headers["X-Encryption-Key"].ToString();
|
|
|
|
|
|
|
|
|
|
var dto = await GetNodeWithAccessAsync(uuid, username, masterKey, db);
|
|
|
|
|
if (dto == null) return Results.NotFound("Node not found or access denied.");
|
|
|
|
|
|
|
|
|
|
return Results.Ok(dto);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST create new node (folder/note)
|
|
|
|
|
group.MapPost("/", async (CreateNodeRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient) =>
|
|
|
|
|
{
|
|
|
|
|
var username = AuthHelper.GetAuthenticatedUser(context, certManager);
|
|
|
|
|
if (string.IsNullOrEmpty(username)) return Results.Unauthorized();
|
|
|
|
|
|
|
|
|
|
var masterKey = context.Request.Headers["X-Encryption-Key"].ToString();
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(req.Name)) return Results.BadRequest("Name is required.");
|
|
|
|
|
|
|
|
|
|
string uuid = req.Uuid;
|
|
|
|
|
if (string.IsNullOrEmpty(uuid))
|
|
|
|
|
{
|
|
|
|
|
do
|
|
|
|
|
{
|
|
|
|
|
uuid = Guid.NewGuid().ToString();
|
|
|
|
|
} while (await db.Nodes.AnyAsync(n => n.Uuid == uuid));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate node specific key (k_0)
|
|
|
|
|
using var aes = System.Security.Cryptography.Aes.Create();
|
|
|
|
|
aes.GenerateKey();
|
|
|
|
|
string rawNodeKey = Convert.ToBase64String(aes.Key);
|
|
|
|
|
|
|
|
|
|
var node = new Node
|
|
|
|
|
{
|
|
|
|
|
Uuid = uuid,
|
|
|
|
|
Name = req.Name,
|
|
|
|
|
Type = req.Type,
|
|
|
|
|
OwnerUsername = username,
|
|
|
|
|
UpdatedAt = DateTime.UtcNow,
|
|
|
|
|
IsDeleted = false
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// By default, it's private. Encrypt content.
|
|
|
|
|
if (string.IsNullOrEmpty(masterKey))
|
|
|
|
|
{
|
|
|
|
|
return Results.BadRequest("X-Encryption-Key is required to create a private node.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
node.Content = SecurityHelper.Encrypt(req.Content ?? "", rawNodeKey);
|
|
|
|
|
|
|
|
|
|
// Store the encrypted node key for owner
|
|
|
|
|
var nodeKey = new NodeKey
|
|
|
|
|
{
|
|
|
|
|
NodeUuid = uuid,
|
|
|
|
|
Username = username,
|
|
|
|
|
EncryptedNodeKey = SecurityHelper.Encrypt(rawNodeKey, masterKey)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
db.Nodes.Add(node);
|
|
|
|
|
db.NodeKeys.Add(nodeKey);
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
_ = Task.Run(async () =>
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var scope = context.RequestServices.CreateScope();
|
|
|
|
|
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
|
2026-06-01 05:49:08 +10:00
|
|
|
var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
|
|
|
|
|
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
|
|
|
|
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient);
|
2026-06-01 05:09:20 +10:00
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine($"Error broadcasting POST change: {ex.Message}");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Return the created DTO
|
|
|
|
|
var dto = new NodeDto(uuid, node.Name, node.Type, req.Content ?? "", username, node.UpdatedAt, true, true, true, true, true, false);
|
|
|
|
|
return Results.Ok(dto);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PUT update node (rename/edit content)
|
|
|
|
|
group.MapPut("/{uuid}", async (string uuid, UpdateNodeRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient) =>
|
|
|
|
|
{
|
|
|
|
|
var username = AuthHelper.GetAuthenticatedUser(context, certManager);
|
|
|
|
|
if (string.IsNullOrEmpty(username)) return Results.Unauthorized();
|
|
|
|
|
|
|
|
|
|
var masterKey = context.Request.Headers["X-Encryption-Key"].ToString();
|
|
|
|
|
|
|
|
|
|
var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid && !n.IsDeleted);
|
|
|
|
|
if (node == null) return Results.NotFound("Node not found.");
|
|
|
|
|
|
|
|
|
|
// Check permissions
|
|
|
|
|
bool isOwner = node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
var acls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync();
|
|
|
|
|
var userAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals(username, StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
var everyoneAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals("everyone", StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
|
|
|
|
|
bool canEdit = isOwner || (userAcl?.CanEdit ?? false) || (everyoneAcl?.CanEdit ?? false);
|
|
|
|
|
bool canRename = isOwner || (userAcl?.CanRename ?? false) || (everyoneAcl?.CanRename ?? false);
|
|
|
|
|
|
|
|
|
|
if (req.Content != null && !canEdit) return Results.Forbid();
|
|
|
|
|
if (req.Name != null && !canRename) return Results.Forbid();
|
|
|
|
|
|
|
|
|
|
bool isPublic = everyoneAcl?.CanView ?? false;
|
|
|
|
|
|
|
|
|
|
if (req.Name != null)
|
|
|
|
|
{
|
|
|
|
|
node.Name = req.Name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (req.Content != null)
|
|
|
|
|
{
|
|
|
|
|
if (isPublic)
|
|
|
|
|
{
|
|
|
|
|
// Public node: plaintext
|
|
|
|
|
node.Content = req.Content;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
// Private node: requires X-Encryption-Key
|
|
|
|
|
if (string.IsNullOrEmpty(masterKey))
|
|
|
|
|
{
|
|
|
|
|
return Results.BadRequest("X-Encryption-Key is required to edit a private node.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch node key
|
|
|
|
|
var nodeKey = await db.NodeKeys.FirstOrDefaultAsync(k => k.NodeUuid == uuid && k.Username.ToLower() == username.ToLower());
|
|
|
|
|
if (nodeKey == null) return Results.BadRequest("Encryption key not authorized for this node.");
|
|
|
|
|
|
|
|
|
|
string rawNodeKey = SecurityHelper.Decrypt(nodeKey.EncryptedNodeKey, masterKey);
|
|
|
|
|
node.Content = SecurityHelper.Encrypt(req.Content, rawNodeKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
node.UpdatedAt = DateTime.UtcNow;
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
_ = Task.Run(async () =>
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var scope = context.RequestServices.CreateScope();
|
|
|
|
|
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
|
2026-06-01 05:49:08 +10:00
|
|
|
var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
|
|
|
|
|
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
|
|
|
|
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient);
|
2026-06-01 05:09:20 +10:00
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine($"Error broadcasting PUT change: {ex.Message}");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return Results.Ok(new { message = "Node updated successfully." });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// DELETE a node (soft delete)
|
|
|
|
|
group.MapDelete("/{uuid}", async (string uuid, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient) =>
|
|
|
|
|
{
|
|
|
|
|
var username = AuthHelper.GetAuthenticatedUser(context, certManager);
|
|
|
|
|
if (string.IsNullOrEmpty(username)) return Results.Unauthorized();
|
|
|
|
|
|
|
|
|
|
var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid && !n.IsDeleted);
|
|
|
|
|
if (node == null) return Results.NotFound("Node not found.");
|
|
|
|
|
|
|
|
|
|
bool isOwner = node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
var acls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync();
|
|
|
|
|
var userAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals(username, StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
var everyoneAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals("everyone", StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
|
|
|
|
|
bool canDelete = isOwner || (userAcl?.CanDelete ?? false) || (everyoneAcl?.CanDelete ?? false);
|
|
|
|
|
if (!canDelete) return Results.Forbid();
|
|
|
|
|
|
|
|
|
|
node.IsDeleted = true;
|
|
|
|
|
node.UpdatedAt = DateTime.UtcNow;
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
_ = Task.Run(async () =>
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var scope = context.RequestServices.CreateScope();
|
|
|
|
|
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
|
2026-06-01 05:49:08 +10:00
|
|
|
var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
|
|
|
|
|
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
|
|
|
|
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient);
|
2026-06-01 05:09:20 +10:00
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine($"Error broadcasting DELETE change: {ex.Message}");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return Results.Ok(new { message = "Node deleted successfully." });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET ACL entries
|
|
|
|
|
group.MapGet("/{uuid}/acl", async (string uuid, HttpContext context, ServerDbContext db, CertificateManager certManager) =>
|
|
|
|
|
{
|
|
|
|
|
var username = AuthHelper.GetAuthenticatedUser(context, certManager);
|
|
|
|
|
if (string.IsNullOrEmpty(username)) return Results.Unauthorized();
|
|
|
|
|
|
|
|
|
|
var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid && !n.IsDeleted);
|
|
|
|
|
if (node == null) return Results.NotFound("Node not found.");
|
|
|
|
|
|
|
|
|
|
// Only owner can manage/view ACLs directly
|
|
|
|
|
if (!node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase)) return Results.Forbid();
|
|
|
|
|
|
|
|
|
|
var acls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync();
|
|
|
|
|
return Results.Ok(acls);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PUT update ACL entries
|
|
|
|
|
group.MapPut("/{uuid}/acl", async (string uuid, List<AclEntryRequest> aclReqs, HttpContext context, ServerDbContext db, CertificateManager certManager) =>
|
|
|
|
|
{
|
|
|
|
|
var username = AuthHelper.GetAuthenticatedUser(context, certManager);
|
|
|
|
|
if (string.IsNullOrEmpty(username)) return Results.Unauthorized();
|
|
|
|
|
|
|
|
|
|
var masterKey = context.Request.Headers["X-Encryption-Key"].ToString();
|
|
|
|
|
|
|
|
|
|
var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid && !n.IsDeleted);
|
|
|
|
|
if (node == null) return Results.NotFound("Node not found.");
|
|
|
|
|
|
|
|
|
|
if (!node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase)) return Results.Forbid();
|
|
|
|
|
|
|
|
|
|
// Validate the request: Adding edit/delete/rename permission requires at least view permission
|
|
|
|
|
foreach (var req in aclReqs)
|
|
|
|
|
{
|
|
|
|
|
if ((req.CanEdit || req.CanDelete || req.CanRename) && !req.CanView)
|
|
|
|
|
{
|
|
|
|
|
return Results.BadRequest("Adding Edit/Delete/Rename permission requires at least View permission.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if everyone view status is changing to handle encryption removal/addition
|
|
|
|
|
bool wasPublic = await db.AclEntries.AnyAsync(a => a.NodeUuid == uuid && a.UserOrRole == "everyone" && a.CanView);
|
|
|
|
|
bool becomesPublic = aclReqs.Any(r => r.UserOrRole == "everyone" && r.CanView);
|
|
|
|
|
|
|
|
|
|
// Fetch the owner's node key (we will need this to encrypt/decrypt when transitioning public/private)
|
|
|
|
|
var ownerNodeKey = await db.NodeKeys.FirstOrDefaultAsync(k => k.NodeUuid == uuid && k.Username.ToLower() == username.ToLower());
|
|
|
|
|
|
|
|
|
|
if (wasPublic && !becomesPublic)
|
|
|
|
|
{
|
|
|
|
|
// Transitioning from PUBLIC to PRIVATE: encrypt the content
|
|
|
|
|
if (string.IsNullOrEmpty(masterKey))
|
|
|
|
|
{
|
|
|
|
|
return Results.BadRequest("X-Encryption-Key is required to transition a public node to private.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate new raw node key k_0
|
|
|
|
|
using var aes = System.Security.Cryptography.Aes.Create();
|
|
|
|
|
aes.GenerateKey();
|
|
|
|
|
string rawNodeKey = Convert.ToBase64String(aes.Key);
|
|
|
|
|
|
|
|
|
|
// Encrypt content
|
|
|
|
|
string plaintext = node.Content; // Currently in plaintext on server
|
|
|
|
|
node.Content = SecurityHelper.Encrypt(plaintext, rawNodeKey);
|
|
|
|
|
|
|
|
|
|
// Save node key for owner
|
|
|
|
|
if (ownerNodeKey == null)
|
|
|
|
|
{
|
|
|
|
|
ownerNodeKey = new NodeKey { NodeUuid = uuid, Username = username };
|
|
|
|
|
db.NodeKeys.Add(ownerNodeKey);
|
|
|
|
|
}
|
|
|
|
|
ownerNodeKey.EncryptedNodeKey = SecurityHelper.Encrypt(rawNodeKey, masterKey);
|
|
|
|
|
}
|
|
|
|
|
else if (!wasPublic && becomesPublic)
|
|
|
|
|
{
|
|
|
|
|
// Transitioning from PRIVATE to PUBLIC: decrypt the content
|
|
|
|
|
if (string.IsNullOrEmpty(masterKey))
|
|
|
|
|
{
|
|
|
|
|
return Results.BadRequest("X-Encryption-Key is required to transition a private node to public.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ownerNodeKey == null)
|
|
|
|
|
{
|
|
|
|
|
return Results.BadRequest("Encryption key not found for this node.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Decrypt content
|
|
|
|
|
string rawNodeKey = SecurityHelper.Decrypt(ownerNodeKey.EncryptedNodeKey, masterKey);
|
|
|
|
|
string plaintext = SecurityHelper.Decrypt(node.Content, rawNodeKey);
|
|
|
|
|
node.Content = plaintext; // Plaintext on server
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove existing ACLs
|
|
|
|
|
var existingAcls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync();
|
|
|
|
|
db.AclEntries.RemoveRange(existingAcls);
|
|
|
|
|
|
|
|
|
|
// Add new ACLs
|
|
|
|
|
foreach (var req in aclReqs)
|
|
|
|
|
{
|
|
|
|
|
db.AclEntries.Add(new AclEntry
|
|
|
|
|
{
|
|
|
|
|
NodeUuid = uuid,
|
|
|
|
|
UserOrRole = req.UserOrRole,
|
|
|
|
|
CanView = req.CanView,
|
|
|
|
|
CanEdit = req.CanEdit,
|
|
|
|
|
CanDelete = req.CanDelete,
|
|
|
|
|
CanRename = req.CanRename
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
node.UpdatedAt = DateTime.UtcNow;
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
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) =>
|
|
|
|
|
{
|
|
|
|
|
var username = AuthHelper.GetAuthenticatedUser(context, certManager);
|
|
|
|
|
if (string.IsNullOrEmpty(username)) return Results.Unauthorized();
|
|
|
|
|
|
|
|
|
|
var masterKey = context.Request.Headers["X-Encryption-Key"].ToString();
|
|
|
|
|
|
|
|
|
|
var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == req.NodeUuid && !n.IsDeleted);
|
|
|
|
|
if (node == null) return Results.NotFound("Node not found.");
|
|
|
|
|
|
|
|
|
|
// Only owner can share
|
|
|
|
|
if (!node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase)) return Results.Forbid();
|
|
|
|
|
|
|
|
|
|
// Verify recipient exists
|
|
|
|
|
var recipientExists = await db.Users.AnyAsync(u => u.Username.ToLower() == req.RecipientUsername.ToLower());
|
|
|
|
|
if (!recipientExists) return Results.BadRequest("Recipient user does not exist.");
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(req.PasskeyK1)) return Results.BadRequest("Sharing passkey k1 is required.");
|
|
|
|
|
|
|
|
|
|
// Retrieve raw node key k_0
|
|
|
|
|
var ownerNodeKey = await db.NodeKeys.FirstOrDefaultAsync(k => k.NodeUuid == req.NodeUuid && k.Username.ToLower() == username.ToLower());
|
|
|
|
|
if (ownerNodeKey == null) return Results.BadRequest("Encryption key not found for this node.");
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrEmpty(masterKey))
|
|
|
|
|
{
|
|
|
|
|
return Results.BadRequest("X-Encryption-Key is required to share a private node.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
string rawNodeKey = SecurityHelper.Decrypt(ownerNodeKey.EncryptedNodeKey, masterKey);
|
|
|
|
|
|
|
|
|
|
// Derive k1 key (derive standard AES 256 key from passkey k1)
|
|
|
|
|
// We use simple SHA256 of the passkey string to yield a 256-bit key
|
|
|
|
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
|
|
|
var k1Bytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(req.PasskeyK1));
|
|
|
|
|
string base64K1 = Convert.ToBase64String(k1Bytes);
|
|
|
|
|
|
|
|
|
|
// Encrypt k_0 using Derived k_1 key
|
|
|
|
|
string encryptedNodeKeyWithK1 = SecurityHelper.Encrypt(rawNodeKey, base64K1);
|
|
|
|
|
|
|
|
|
|
// Add or update PendingShare
|
|
|
|
|
var pendingShare = await db.PendingShares.FirstOrDefaultAsync(s => s.NodeUuid == req.NodeUuid && s.RecipientUsername.ToLower() == req.RecipientUsername.ToLower());
|
|
|
|
|
if (pendingShare == null)
|
|
|
|
|
{
|
|
|
|
|
pendingShare = new PendingShare
|
|
|
|
|
{
|
|
|
|
|
NodeUuid = req.NodeUuid,
|
|
|
|
|
RecipientUsername = req.RecipientUsername
|
|
|
|
|
};
|
|
|
|
|
db.PendingShares.Add(pendingShare);
|
|
|
|
|
}
|
|
|
|
|
pendingShare.EncryptedNodeKeyWithK1 = encryptedNodeKeyWithK1;
|
|
|
|
|
|
|
|
|
|
// Grant recipient view access in ACL
|
|
|
|
|
var hasAcl = await db.AclEntries.AnyAsync(a => a.NodeUuid == req.NodeUuid && a.UserOrRole.ToLower() == req.RecipientUsername.ToLower());
|
|
|
|
|
if (!hasAcl)
|
|
|
|
|
{
|
|
|
|
|
db.AclEntries.Add(new AclEntry
|
|
|
|
|
{
|
|
|
|
|
NodeUuid = req.NodeUuid,
|
|
|
|
|
UserOrRole = req.RecipientUsername,
|
|
|
|
|
CanView = true // Standard view access upon sharing
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
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) =>
|
|
|
|
|
{
|
|
|
|
|
var username = AuthHelper.GetAuthenticatedUser(context, certManager);
|
|
|
|
|
if (string.IsNullOrEmpty(username)) return Results.Unauthorized();
|
|
|
|
|
|
|
|
|
|
var masterKey = context.Request.Headers["X-Encryption-Key"].ToString();
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(req.NodeUuid)) return Results.BadRequest("GUID (Node UUID) is required.");
|
|
|
|
|
if (string.IsNullOrWhiteSpace(req.PasskeyK1)) return Results.BadRequest("Passkey k1 is required.");
|
|
|
|
|
|
|
|
|
|
// Fetch PendingShare
|
|
|
|
|
var pendingShare = await db.PendingShares.FirstOrDefaultAsync(s => s.NodeUuid == req.NodeUuid && s.RecipientUsername.ToLower() == username.ToLower());
|
|
|
|
|
if (pendingShare == null) return Results.NotFound("Pending share not found for this node and user.");
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrEmpty(masterKey))
|
|
|
|
|
{
|
|
|
|
|
return Results.BadRequest("X-Encryption-Key is required to accept a share.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Derive k1 key
|
|
|
|
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
|
|
|
var k1Bytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(req.PasskeyK1));
|
|
|
|
|
string base64K1 = Convert.ToBase64String(k1Bytes);
|
|
|
|
|
|
|
|
|
|
// Decrypt k_0 using derived k_1 key
|
|
|
|
|
string rawNodeKey;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
rawNodeKey = SecurityHelper.Decrypt(pendingShare.EncryptedNodeKeyWithK1, base64K1);
|
|
|
|
|
|
|
|
|
|
// Simple validation: check if decrypted value is a valid Base64 string of 32 bytes (key length)
|
|
|
|
|
var testBytes = Convert.FromBase64String(rawNodeKey);
|
|
|
|
|
if (testBytes.Length != 32) throw new Exception("Invalid key length.");
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
return Results.BadRequest("Invalid passkey k1. Decryption failed.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Encrypt k_0 using Recipient's masterKey
|
|
|
|
|
string encryptedNodeKey = SecurityHelper.Encrypt(rawNodeKey, masterKey);
|
|
|
|
|
|
|
|
|
|
// Add NodeKey record for the recipient
|
|
|
|
|
var nodeKey = await db.NodeKeys.FirstOrDefaultAsync(k => k.NodeUuid == req.NodeUuid && k.Username.ToLower() == username.ToLower());
|
|
|
|
|
if (nodeKey == null)
|
|
|
|
|
{
|
|
|
|
|
nodeKey = new NodeKey
|
|
|
|
|
{
|
|
|
|
|
NodeUuid = req.NodeUuid,
|
|
|
|
|
Username = username
|
|
|
|
|
};
|
|
|
|
|
db.NodeKeys.Add(nodeKey);
|
|
|
|
|
}
|
|
|
|
|
nodeKey.EncryptedNodeKey = encryptedNodeKey;
|
|
|
|
|
|
|
|
|
|
// Delete pending share
|
|
|
|
|
db.PendingShares.Remove(pendingShare);
|
|
|
|
|
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
return Results.Ok(new { message = "Share accepted and linked successfully." });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public record NodeDto(
|
|
|
|
|
string Uuid,
|
|
|
|
|
string Name,
|
|
|
|
|
string Type,
|
|
|
|
|
string Content,
|
|
|
|
|
string OwnerUsername,
|
|
|
|
|
DateTime UpdatedAt,
|
|
|
|
|
bool IsOwner,
|
|
|
|
|
bool CanView,
|
|
|
|
|
bool CanEdit,
|
|
|
|
|
bool CanDelete,
|
|
|
|
|
bool CanRename,
|
|
|
|
|
bool IsPublic
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
public record CreateNodeRequest(string? Uuid, string Name, string Type, string? Content);
|
|
|
|
|
public record UpdateNodeRequest(string? Name, string? Content);
|
|
|
|
|
public record AclEntryRequest(string UserOrRole, bool CanView, bool CanEdit, bool CanDelete, bool CanRename);
|
|
|
|
|
public record ShareNodeRequest(string NodeUuid, string RecipientUsername, string PasskeyK1);
|
|
|
|
|
public record AcceptShareRequest(string NodeUuid, string PasskeyK1);
|
|
|
|
|
public record LinkFolderRequest(string NodeUuid, string TargetParentUuid);
|