diff --git a/.idea/.idea.Messager/.idea/.gitignore b/.idea/.idea.Messager/.idea/.gitignore
new file mode 100644
index 0000000..920a021
--- /dev/null
+++ b/.idea/.idea.Messager/.idea/.gitignore
@@ -0,0 +1,15 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/projectSettingsUpdater.xml
+/contentModel.xml
+/.idea.Messager.iml
+/modules.xml
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/API/API.csproj b/API/API.csproj
index fdfa2cb..caaedac 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -8,6 +8,8 @@
+
+
diff --git a/API/Contracts/AuthContracts.cs b/API/Contracts/AuthContracts.cs
index 2fc5309..da1cc37 100644
--- a/API/Contracts/AuthContracts.cs
+++ b/API/Contracts/AuthContracts.cs
@@ -1,4 +1,4 @@
-namespace API.Contracts;
+namespace Api.Contracts;
public sealed record RegisterRequest(string DerBase64, string UserName, uint UserTag);
public sealed record RegisterResponse(string FingerprintSha512, string UserName, uint UserTag);
diff --git a/API/Contracts/KeyExchangeContracts.cs b/API/Contracts/KeyExchangeContracts.cs
index 6fce2e1..d8adb2e 100644
--- a/API/Contracts/KeyExchangeContracts.cs
+++ b/API/Contracts/KeyExchangeContracts.cs
@@ -1,4 +1,4 @@
-namespace API.Contracts;
+namespace Api.Contracts;
public sealed record SendKeyExchangeRequest(string ToPublicKey, string EncryptedPrivateKeyBase64);
public sealed record KeyExchangeResponse(string FromPublicKey, string ToPublicKey, string EncryptedPrivateKeyBase64, DateTime CreatedAt);
diff --git a/API/Contracts/MessageContracts.cs b/API/Contracts/MessageContracts.cs
index d9d41ca..f137b86 100644
--- a/API/Contracts/MessageContracts.cs
+++ b/API/Contracts/MessageContracts.cs
@@ -1,4 +1,4 @@
-namespace API.Contracts;
+namespace Api.Contracts;
public sealed record SendMessageRequest(string ToPublicKey, string EncryptedContentBase64, string MessageHash);
public sealed record MessageResponse(string FromPublicKey, string ToPublicKey, string EncryptedContentBase64, string MessageHash, DateTime CreatedAt);
diff --git a/API/Contracts/PublicKeyContracts.cs b/API/Contracts/PublicKeyContracts.cs
index 82ec6d5..20d0d51 100644
--- a/API/Contracts/PublicKeyContracts.cs
+++ b/API/Contracts/PublicKeyContracts.cs
@@ -1,3 +1,3 @@
-namespace API.Contracts;
+namespace Api.Contracts;
public sealed record PublicKeyProfileResponse(string FingerprintSha512, string UserName, uint UserTag, string PublicKeyDerBase64);
diff --git a/API/Contracts/SyncContracts.cs b/API/Contracts/SyncContracts.cs
index 4f5fc4f..97e1b78 100644
--- a/API/Contracts/SyncContracts.cs
+++ b/API/Contracts/SyncContracts.cs
@@ -1,4 +1,4 @@
-namespace API.Contracts;
+namespace Api.Contracts;
public sealed record SyncDeltaResponse(
DateTime ServerTimeUtc,
diff --git a/API/Controllers/AuthController.cs b/API/Controllers/AuthController.cs
new file mode 100644
index 0000000..9b1aecc
--- /dev/null
+++ b/API/Controllers/AuthController.cs
@@ -0,0 +1,63 @@
+using Api.Contracts;
+using Api.Security;
+using Application.Commands;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace Api.Controllers;
+
+[ApiController]
+[Route("/api/auth")]
+[EnableRateLimiting("auth")]
+public sealed class AuthController(IMediator mediator, JwtTokenIssuer jwtTokenIssuer) : ControllerBase
+{
+ [HttpPost("register")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(RegisterResponse))]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
+ [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ErrorResponse))]
+ public async Task RegisterAsync(
+ [FromBody] RegisterRequest request,
+ CancellationToken cancellationToken)
+ {
+ RegisterResult result = await mediator.Send(
+ new RegisterCommand(request.DerBase64, request.UserName, request.UserTag),
+ cancellationToken);
+
+ return Created(
+ $"/api/public-keys/{result.FingerprintSha512}",
+ new RegisterResponse(result.FingerprintSha512, result.UserName, result.UserTag));
+ }
+
+ [HttpPost("challenge")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ChallengeResponse))]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
+ [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
+ public async Task ChallengeAsync(
+ [FromBody] ChallengeRequest request,
+ CancellationToken cancellationToken)
+ {
+ byte[] challenge = await mediator.Send(new GetLoginChallengeCommand(request.FingerprintSha512), cancellationToken);
+ return Ok(new ChallengeResponse(Convert.ToBase64String(challenge)));
+ }
+
+ [HttpPost("login")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LoginResponse))]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))]
+ public async Task LoginAsync(
+ [FromBody] LoginRequest request,
+ CancellationToken cancellationToken)
+ {
+ await mediator.Send(
+ new LoginCommand(request.FingerprintSha512, request.ChallengeBase64, request.SignatureBase64),
+ cancellationToken);
+
+ (string token, DateTime expiresAtUtc) = jwtTokenIssuer.Generate(request.FingerprintSha512);
+ return Ok(new LoginResponse(token, expiresAtUtc));
+ }
+}
diff --git a/API/Controllers/KeyExchangesController.cs b/API/Controllers/KeyExchangesController.cs
new file mode 100644
index 0000000..ede360f
--- /dev/null
+++ b/API/Controllers/KeyExchangesController.cs
@@ -0,0 +1,52 @@
+using Api.Contracts;
+using Api.Extensions;
+using Application.Commands;
+using Application.DTOs;
+using Application.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("/api/key-exchanges")]
+public sealed class KeyExchangesController(IMediator mediator) : ControllerBase
+{
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(KeyExchangeResponse))]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task SendKeyExchangeAsync(
+ [FromBody] SendKeyExchangeRequest request,
+ CancellationToken cancellationToken)
+ {
+ string fingerprint = User.GetFingerprint();
+
+ KeyExchangeDto keyExchange = await mediator.Send(
+ new SendKeyExchangeCommand(fingerprint, request.ToPublicKey, request.EncryptedPrivateKeyBase64),
+ cancellationToken);
+
+ return Ok(keyExchange.ToResponse());
+ }
+
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task GetKeyExchangesAsync(
+ [FromQuery] string toPublicKey,
+ [FromQuery] DateTime? fromDate,
+ [FromQuery] DateTime? toDate,
+ CancellationToken cancellationToken)
+ {
+ string fingerprint = User.GetFingerprint();
+
+ IReadOnlyList keyExchanges = await mediator.Send(
+ new GetKeyExchangesQuery(fingerprint, toPublicKey, fromDate, toDate),
+ cancellationToken);
+
+ return Ok(keyExchanges.Select(k => k.ToResponse()));
+ }
+}
diff --git a/API/Controllers/MessagesController.cs b/API/Controllers/MessagesController.cs
new file mode 100644
index 0000000..0a3db58
--- /dev/null
+++ b/API/Controllers/MessagesController.cs
@@ -0,0 +1,52 @@
+using Api.Contracts;
+using Api.Extensions;
+using Application.Commands;
+using Application.DTOs;
+using Application.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("/api/messages")]
+public sealed class MessagesController(IMediator mediator) : ControllerBase
+{
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MessageResponse))]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task SendMessageAsync(
+ [FromBody] SendMessageRequest request,
+ CancellationToken cancellationToken)
+ {
+ string fingerprint = User.GetFingerprint();
+
+ MessageDto message = await mediator.Send(
+ new SendMessageCommand(fingerprint, request.ToPublicKey, request.EncryptedContentBase64, request.MessageHash),
+ cancellationToken);
+
+ return Ok(message.ToResponse());
+ }
+
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task GetMessagesAsync(
+ [FromQuery] string toPublicKey,
+ [FromQuery] DateTime? fromDate,
+ [FromQuery] DateTime? toDate,
+ CancellationToken cancellationToken)
+ {
+ string fingerprint = User.GetFingerprint();
+
+ IReadOnlyList messages = await mediator.Send(
+ new GetMessagesQuery(fingerprint, toPublicKey, fromDate, toDate),
+ cancellationToken);
+
+ return Ok(messages.Select(m => m.ToResponse()));
+ }
+}
diff --git a/API/Controllers/PublicKeysController.cs b/API/Controllers/PublicKeysController.cs
new file mode 100644
index 0000000..de1392a
--- /dev/null
+++ b/API/Controllers/PublicKeysController.cs
@@ -0,0 +1,37 @@
+using Api.Contracts;
+using Application.DTOs;
+using Application.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("/api/public-keys")]
+[EnableRateLimiting("search")]
+public sealed class PublicKeysController(IMediator mediator) : ControllerBase
+{
+ [HttpGet("search")]
+ [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))]
+ [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task SearchAsync(
+ [FromQuery] string userName,
+ [FromQuery] uint? userTag,
+ [FromQuery] int? limit,
+ CancellationToken cancellationToken)
+ {
+ IReadOnlyList results = await mediator.Send(
+ new SearchPublicKeysQuery(userName, userTag, limit),
+ cancellationToken);
+
+ return Ok(results.Select(p => new PublicKeyProfileResponse(
+ p.FingerprintSha512,
+ p.UserName,
+ p.UserTag,
+ Convert.ToBase64String(p.Der))));
+ }
+}
diff --git a/API/Controllers/SyncController.cs b/API/Controllers/SyncController.cs
new file mode 100644
index 0000000..685a717
--- /dev/null
+++ b/API/Controllers/SyncController.cs
@@ -0,0 +1,175 @@
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Api.Contracts;
+using Api.Extensions;
+using Api.Realtime;
+using Application.DTOs;
+using Application.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("/api/sync")]
+public sealed class SyncController(IMediator mediator) : ControllerBase
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+ private static readonly Regex FingerprintPattern = new(@"^[0-9a-fA-F]{128}$", RegexOptions.Compiled);
+
+ [HttpGet("delta")]
+ [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(SyncDeltaResponse))]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task GetDeltaAsync(
+ [FromQuery] DateTime? since,
+ [FromQuery] int? limit,
+ CancellationToken cancellationToken)
+ {
+ string fingerprint = User.GetFingerprint();
+
+ SyncDeltaDto delta = await mediator.Send(
+ new GetSyncDeltaQuery(fingerprint, since, limit, null),
+ cancellationToken);
+
+ return Ok(delta.ToResponse());
+ }
+
+ [HttpGet("/ws/sync")]
+ [AllowAnonymous]
+ public async Task HandleInboxWebSocketAsync(
+ [FromQuery] DateTime? since,
+ [FromQuery] int? limit)
+ {
+ if (!HttpContext.WebSockets.IsWebSocketRequest)
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
+ await HttpContext.Response.WriteAsync("WebSocket request expected.");
+ return;
+ }
+
+ if (!HttpContext.User.TryGetFingerprint(out string? fingerprint))
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return;
+ }
+
+ using WebSocket socket = await HttpContext.WebSockets.AcceptWebSocketAsync();
+ await StreamSyncAsync(socket, fingerprint!, peerFilter: null, since, limit, HttpContext);
+ }
+
+ [HttpGet("/ws/conversations/{peerFingerprint}")]
+ [AllowAnonymous]
+ public async Task HandleConversationWebSocketAsync(
+ string peerFingerprint,
+ [FromQuery] DateTime? since,
+ [FromQuery] int? limit)
+ {
+ if (!HttpContext.WebSockets.IsWebSocketRequest)
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
+ await HttpContext.Response.WriteAsync("WebSocket request expected.");
+ return;
+ }
+
+ if (!HttpContext.User.TryGetFingerprint(out string? fingerprint))
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return;
+ }
+
+ if (!FingerprintPattern.IsMatch(peerFingerprint))
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
+ await HttpContext.Response.WriteAsync("peerFingerprint must be a 128-character hexadecimal string.");
+ return;
+ }
+
+ using WebSocket socket = await HttpContext.WebSockets.AcceptWebSocketAsync();
+ await StreamSyncAsync(socket, fingerprint!, peerFingerprint, since, limit, HttpContext);
+ }
+
+ private async Task StreamSyncAsync(
+ WebSocket socket,
+ string fingerprint,
+ string? peerFilter,
+ DateTime? since,
+ int? limit,
+ HttpContext context)
+ {
+ SyncNotificationHub hub = context.RequestServices.GetRequiredService();
+ int boundedLimit = Math.Clamp(limit ?? 700, 1, 1000);
+ DateTime cursor = since?.ToUniversalTime() ?? DateTime.UtcNow;
+ long lastVersion = hub.GetVersion(fingerprint, peerFilter);
+
+ cursor = await SendDeltaIfChangedAsync(socket, fingerprint, peerFilter, boundedLimit, cursor, context.RequestServices, context.RequestAborted);
+
+ while (socket.State == WebSocketState.Open && !context.RequestAborted.IsCancellationRequested)
+ {
+ try
+ {
+ lastVersion = await hub.WaitForChangeAsync(fingerprint, peerFilter, lastVersion, context.RequestAborted);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+
+ if (socket.State != WebSocketState.Open)
+ break;
+
+ cursor = await SendDeltaIfChangedAsync(socket, fingerprint, peerFilter, boundedLimit, cursor, context.RequestServices, context.RequestAborted);
+ }
+ }
+
+ private async Task SendDeltaIfChangedAsync(
+ WebSocket socket,
+ string fingerprint,
+ string? peerFilter,
+ int limit,
+ DateTime cursor,
+ IServiceProvider services,
+ CancellationToken cancellationToken)
+ {
+ await using AsyncServiceScope scope = services.CreateAsyncScope();
+ IMediator scopedMediator = scope.ServiceProvider.GetRequiredService();
+
+ SyncDeltaDto delta = await scopedMediator.Send(
+ new GetSyncDeltaQuery(fingerprint, cursor, limit, peerFilter),
+ cancellationToken);
+
+ if (delta.Messages.Count == 0 && delta.KeyExchanges.Count == 0)
+ return cursor;
+
+ string json = JsonSerializer.Serialize(
+ new { type = "sync-delta", payload = delta.ToResponse() },
+ JsonOptions);
+
+ await socket.SendAsync(
+ Encoding.UTF8.GetBytes(json),
+ WebSocketMessageType.Text,
+ endOfMessage: true,
+ cancellationToken);
+
+ return LatestTimestamp(delta, cursor);
+ }
+
+ private static DateTime LatestTimestamp(SyncDeltaDto delta, DateTime fallback)
+ {
+ DateTime latest = fallback;
+ foreach (MessageDto m in delta.Messages)
+ {
+ DateTime utc = m.CreatedAt.ToUniversalTime();
+ if (utc > latest) latest = utc;
+ }
+ foreach (KeyExchangeDto k in delta.KeyExchanges)
+ {
+ DateTime utc = k.CreatedAt.ToUniversalTime();
+ if (utc > latest) latest = utc;
+ }
+ return latest;
+ }
+}
diff --git a/API/DependencyInjection.cs b/API/DependencyInjection.cs
new file mode 100644
index 0000000..d64edf3
--- /dev/null
+++ b/API/DependencyInjection.cs
@@ -0,0 +1,86 @@
+using System.Security.Claims;
+using System.Text;
+using System.Threading.RateLimiting;
+using Api.Middleware;
+using Api.Realtime;
+using Api.Security;
+using Application.Interfaces;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Api;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection RegisterApiServices(this IServiceCollection services, string jwtSigningKey)
+ {
+ services
+ .AddSingleton(new JwtTokenIssuer(jwtSigningKey))
+ .AddSingleton()
+ .AddSingleton();
+
+ services
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.TokenValidationParameters = BuildTokenValidationParameters(jwtSigningKey);
+ options.Events = new JwtBearerEvents
+ {
+ OnMessageReceived = context =>
+ {
+ string? token = context.Request.Query["access_token"];
+ if (!string.IsNullOrWhiteSpace(token))
+ context.Token = token;
+ return Task.CompletedTask;
+ }
+ };
+ });
+
+ services
+ .AddAuthorization()
+ .AddExceptionHandler()
+ .AddProblemDetails()
+ .AddControllers();
+
+ services.AddRateLimiter(ConfigureRateLimiting);
+
+ return services;
+ }
+
+ private static TokenValidationParameters BuildTokenValidationParameters(string signingKey) => new()
+ {
+ ValidateIssuer = true,
+ ValidIssuer = JwtTokenIssuer.Issuer,
+ ValidateAudience = true,
+ ValidAudience = JwtTokenIssuer.Audience,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromSeconds(30),
+ NameClaimType = ClaimTypes.NameIdentifier
+ };
+
+ private static void ConfigureRateLimiting(RateLimiterOptions options)
+ {
+ options.AddSlidingWindowLimiter("auth", o =>
+ {
+ o.PermitLimit = 10;
+ o.Window = TimeSpan.FromMinutes(1);
+ o.SegmentsPerWindow = 6;
+ o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
+ o.QueueLimit = 0;
+ });
+
+ options.AddSlidingWindowLimiter("search", o =>
+ {
+ o.PermitLimit = 30;
+ o.Window = TimeSpan.FromMinutes(1);
+ o.SegmentsPerWindow = 6;
+ o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
+ o.QueueLimit = 0;
+ });
+
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ }
+}
diff --git a/API/Endpoints/AuthEndpoints.cs b/API/Endpoints/AuthEndpoints.cs
deleted file mode 100644
index 0d7f92d..0000000
--- a/API/Endpoints/AuthEndpoints.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using API.Contracts;
-using API.Security;
-using Application;
-using Domain;
-using Infrastructure.Persistence;
-using Infrastructure.Persistence.Models;
-using Microsoft.EntityFrameworkCore;
-
-namespace API.Endpoints;
-
-internal static class AuthEndpoints
-{
- public static RouteGroupBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
- {
- RouteGroupBuilder group = app.MapGroup("/api/auth").RequireRateLimiting("auth");
-
- group.MapPost("/register", async (
- RegisterRequest request,
- RegisterHandler handler,
- MessagerDbContext dbContext,
- CancellationToken cancellationToken) =>
- {
- try
- {
- byte[] der = Convert.FromBase64String(request.DerBase64);
- PublicKey publicKey = handler.Handle(der, request.UserName, request.UserTag);
-
- bool exists = await dbContext.PublicKeys
- .AnyAsync(x => x.FingerprintSha512 == publicKey.FingerprintSha512, cancellationToken);
-
- if (exists)
- return Results.Conflict(new ErrorResponse("Public key already registered."));
-
- DateTime now = DateTime.UtcNow;
-
- dbContext.PublicKeys.Add(new PublicKeyRecord
- {
- FingerprintSha512 = publicKey.FingerprintSha512,
- Der = publicKey.Der,
- UserName = publicKey.UserName,
- UserTag = publicKey.UserTag,
- CreatedAt = now,
- UpdatedAt = now
- });
-
- await dbContext.SaveChangesAsync(cancellationToken);
-
- return Results.Created($"/api/public-keys/{publicKey.FingerprintSha512}", new RegisterResponse(
- publicKey.FingerprintSha512,
- publicKey.UserName,
- publicKey.UserTag));
- }
- catch (FormatException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (ArgumentException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (InvalidOperationException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- });
-
- group.MapPost("/challenge", (
- ChallengeRequest request,
- GetLoginChallengeHandler handler) =>
- {
- try
- {
- byte[] challenge = handler.Handle(request.FingerprintSha512);
- return Results.Ok(new ChallengeResponse(Convert.ToBase64String(challenge)));
- }
- catch (ArgumentException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (InvalidOperationException ex)
- {
- return Results.NotFound(new ErrorResponse(ex.Message));
- }
- });
-
- group.MapPost("/login", (
- LoginRequest request,
- LoginHandler handler,
- JwtTokenIssuer jwtTokenIssuer) =>
- {
- try
- {
- byte[] challenge = Convert.FromBase64String(request.ChallengeBase64);
- byte[] signature = Convert.FromBase64String(request.SignatureBase64);
- handler.Handle(request.FingerprintSha512, challenge, signature);
-
- (string token, DateTime expiresAtUtc) = jwtTokenIssuer.Generate(request.FingerprintSha512);
- return Results.Ok(new LoginResponse(token, expiresAtUtc));
- }
- catch (FormatException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (ArgumentException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (InvalidOperationException)
- {
- return Results.Unauthorized();
- }
- });
-
- return group;
- }
-}
diff --git a/API/Endpoints/EndpointHelpers.cs b/API/Endpoints/EndpointHelpers.cs
deleted file mode 100644
index a3c41e6..0000000
--- a/API/Endpoints/EndpointHelpers.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System.Security.Claims;
-using API.Contracts;
-using Infrastructure.Services;
-
-namespace API.Endpoints;
-
-internal static class EndpointHelpers
-{
- public static bool TrySetCurrentPublicKey(ClaimsPrincipal user, CurrentPublicKeyAccessor accessor, out IResult? error)
- {
- string? currentPublicKey = user.FindFirstValue(ClaimTypes.NameIdentifier);
-
- if (string.IsNullOrWhiteSpace(currentPublicKey))
- {
- error = Results.Unauthorized();
- return false;
- }
-
- accessor.SetFingerprintSha512(currentPublicKey);
- error = null;
- return true;
- }
-}
diff --git a/API/Endpoints/KeyExchangeEndpoints.cs b/API/Endpoints/KeyExchangeEndpoints.cs
deleted file mode 100644
index 7145f9e..0000000
--- a/API/Endpoints/KeyExchangeEndpoints.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using System.Security.Claims;
-using API.Contracts;
-using API.Realtime;
-using Application;
-using Domain;
-using Infrastructure.Persistence;
-using Infrastructure.Persistence.Models;
-using Infrastructure.Services;
-using Microsoft.EntityFrameworkCore;
-
-namespace API.Endpoints;
-
-internal static class KeyExchangeEndpoints
-{
- public static RouteGroupBuilder MapKeyExchangeEndpoints(this IEndpointRouteBuilder app)
- {
- RouteGroupBuilder group = app.MapGroup("/api/key-exchanges").RequireAuthorization();
-
- group.MapPost("/", async (
- ClaimsPrincipal user,
- SendKeyExchangeRequest request,
- CurrentPublicKeyAccessor accessor,
- SendKeyExchangeHandler handler,
- MessagerDbContext dbContext,
- SyncNotificationHub syncNotificationHub,
- CancellationToken cancellationToken) =>
- {
- if (!EndpointHelpers.TrySetCurrentPublicKey(user, accessor, out IResult? error))
- return error!;
-
- try
- {
- KeyExchange keyExchange = handler.Handle(
- request.ToPublicKey,
- Convert.FromBase64String(request.EncryptedPrivateKeyBase64));
-
- KeyExchangeRecord? existing = await dbContext.KeyExchanges
- .SingleOrDefaultAsync(
- x => x.FromPublicKey == keyExchange.FromPublicKey && x.ToPublicKey == keyExchange.ToPublicKey,
- cancellationToken);
-
- if (existing is null)
- {
- dbContext.KeyExchanges.Add(new KeyExchangeRecord
- {
- FromPublicKey = keyExchange.FromPublicKey,
- ToPublicKey = keyExchange.ToPublicKey,
- EncryptedPrivateKey = keyExchange.EncryptedPrivateKey,
- CreatedAt = DateTime.UtcNow
- });
- }
- else
- {
- existing.EncryptedPrivateKey = keyExchange.EncryptedPrivateKey;
- existing.CreatedAt = DateTime.UtcNow;
- }
-
- await dbContext.SaveChangesAsync(cancellationToken);
- syncNotificationHub.NotifyKeyExchange(keyExchange.FromPublicKey, keyExchange.ToPublicKey);
-
- return Results.Ok(new KeyExchangeResponse(
- keyExchange.FromPublicKey,
- keyExchange.ToPublicKey,
- Convert.ToBase64String(keyExchange.EncryptedPrivateKey),
- keyExchange.CreatedAt));
- }
- catch (FormatException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (ArgumentException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (InvalidOperationException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- });
-
- group.MapGet("/", (
- ClaimsPrincipal user,
- string toPublicKey,
- DateTime? fromDate,
- DateTime? toDate,
- CurrentPublicKeyAccessor accessor,
- GetKeyExchangesHandler handler) =>
- {
- if (!EndpointHelpers.TrySetCurrentPublicKey(user, accessor, out IResult? error))
- return error!;
-
- try
- {
- IReadOnlyList keyExchanges = handler.Handle(toPublicKey, fromDate, toDate);
- return Results.Ok(keyExchanges.Select(x => new KeyExchangeResponse(
- x.FromPublicKey,
- x.ToPublicKey,
- Convert.ToBase64String(x.EncryptedPrivateKey),
- x.CreatedAt)));
- }
- catch (ArgumentException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (InvalidOperationException ex)
- {
- return Results.NotFound(new ErrorResponse(ex.Message));
- }
- });
-
- return group;
- }
-}
diff --git a/API/Endpoints/MessageEndpoints.cs b/API/Endpoints/MessageEndpoints.cs
deleted file mode 100644
index 042c85a..0000000
--- a/API/Endpoints/MessageEndpoints.cs
+++ /dev/null
@@ -1,103 +0,0 @@
-using System.Security.Claims;
-using API.Contracts;
-using API.Realtime;
-using Application;
-using Domain;
-using Infrastructure.Persistence;
-using Infrastructure.Persistence.Models;
-using Infrastructure.Services;
-
-namespace API.Endpoints;
-
-internal static class MessageEndpoints
-{
- public static RouteGroupBuilder MapMessageEndpoints(this IEndpointRouteBuilder app)
- {
- RouteGroupBuilder group = app.MapGroup("/api/messages").RequireAuthorization();
-
- group.MapPost("/", async (
- ClaimsPrincipal user,
- SendMessageRequest request,
- CurrentPublicKeyAccessor accessor,
- SendMessageHandler handler,
- MessagerDbContext dbContext,
- SyncNotificationHub syncNotificationHub,
- CancellationToken cancellationToken) =>
- {
- if (!EndpointHelpers.TrySetCurrentPublicKey(user, accessor, out IResult? error))
- return error!;
-
- try
- {
- Message message = handler.Handle(
- request.ToPublicKey,
- Convert.FromBase64String(request.EncryptedContentBase64),
- request.MessageHash);
-
- dbContext.Messages.Add(new MessageRecord
- {
- FromPublicKey = message.FromPublicKey,
- ToPublicKey = message.ToPublicKey,
- EncryptedContent = message.EncryptedContent,
- MessageHash = message.MessageHash,
- CreatedAt = DateTime.UtcNow
- });
-
- await dbContext.SaveChangesAsync(cancellationToken);
- syncNotificationHub.NotifyMessage(message.FromPublicKey, message.ToPublicKey);
-
- return Results.Ok(new MessageResponse(
- message.FromPublicKey,
- message.ToPublicKey,
- Convert.ToBase64String(message.EncryptedContent),
- message.MessageHash,
- message.CreatedAt));
- }
- catch (FormatException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (ArgumentException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (InvalidOperationException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- });
-
- group.MapGet("/", (
- ClaimsPrincipal user,
- string toPublicKey,
- DateTime? fromDate,
- DateTime? toDate,
- CurrentPublicKeyAccessor accessor,
- GetMessagesHandler handler) =>
- {
- if (!EndpointHelpers.TrySetCurrentPublicKey(user, accessor, out IResult? error))
- return error!;
-
- try
- {
- IReadOnlyList messages = handler.Handle(toPublicKey, fromDate, toDate);
- return Results.Ok(messages.Select(x => new MessageResponse(
- x.FromPublicKey,
- x.ToPublicKey,
- Convert.ToBase64String(x.EncryptedContent),
- x.MessageHash,
- x.CreatedAt)));
- }
- catch (ArgumentException ex)
- {
- return Results.BadRequest(new ErrorResponse(ex.Message));
- }
- catch (InvalidOperationException ex)
- {
- return Results.NotFound(new ErrorResponse(ex.Message));
- }
- });
-
- return group;
- }
-}
diff --git a/API/Endpoints/PublicKeyEndpoints.cs b/API/Endpoints/PublicKeyEndpoints.cs
deleted file mode 100644
index 7eca5ea..0000000
--- a/API/Endpoints/PublicKeyEndpoints.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using API.Contracts;
-using Infrastructure.Persistence;
-using Microsoft.EntityFrameworkCore;
-
-namespace API.Endpoints;
-
-internal static class PublicKeyEndpoints
-{
- public static RouteGroupBuilder MapPublicKeyEndpoints(this IEndpointRouteBuilder app)
- {
- RouteGroupBuilder group = app.MapGroup("/api/public-keys").RequireAuthorization().RequireRateLimiting("search");
-
- group.MapGet("/search", async (
- string userName,
- uint? userTag,
- int? limit,
- MessagerDbContext dbContext,
- CancellationToken cancellationToken) =>
- {
- string normalizedUserName = userName.Trim();
- if (normalizedUserName.Length < 2)
- return Results.BadRequest(new ErrorResponse("userName must have at least 2 characters."));
-
- int boundedLimit = Math.Clamp(limit ?? 25, 1, 100);
-
- string escapedUserName = normalizedUserName
- .Replace("\\", "\\\\")
- .Replace("%", "\\%")
- .Replace("_", "\\_");
-
- IQueryable query = dbContext.PublicKeys
- .Where(x => EF.Functions.ILike(x.UserName, $"%{escapedUserName}%", "\\"));
-
- if (userTag.HasValue)
- query = query.Where(x => x.UserTag == userTag.Value);
-
- IReadOnlyList results = await query
- .OrderBy(x => x.UserName)
- .ThenBy(x => x.UserTag)
- .ThenBy(x => x.FingerprintSha512)
- .Take(boundedLimit)
- .Select(x => new PublicKeyProfileResponse(
- x.FingerprintSha512,
- x.UserName,
- x.UserTag,
- Convert.ToBase64String(x.Der)))
- .ToListAsync(cancellationToken);
-
- return Results.Ok(results);
- });
-
- return group;
- }
-}
diff --git a/API/Endpoints/SyncEndpoints.cs b/API/Endpoints/SyncEndpoints.cs
deleted file mode 100644
index dc366d2..0000000
--- a/API/Endpoints/SyncEndpoints.cs
+++ /dev/null
@@ -1,378 +0,0 @@
-using System.Security.Claims;
-using System.IdentityModel.Tokens.Jwt;
-using System.Net.WebSockets;
-using System.Text;
-using System.Text.Json;
-using System.Text.RegularExpressions;
-using API.Contracts;
-using API.Realtime;
-using Infrastructure.Persistence;
-using Infrastructure.Services;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.IdentityModel.Tokens;
-
-namespace API.Endpoints;
-
-internal static class SyncEndpoints
-{
- private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
- private static readonly Regex FingerprintPattern = new(@"^[0-9a-fA-F]{128}$", RegexOptions.Compiled);
-
- public static RouteGroupBuilder MapSyncEndpoints(this IEndpointRouteBuilder app)
- {
- RouteGroupBuilder group = app.MapGroup("/api/sync").RequireAuthorization();
-
- group.MapGet("/delta", async (
- ClaimsPrincipal user,
- DateTime? since,
- int? limit,
- CurrentPublicKeyAccessor accessor,
- MessagerDbContext dbContext,
- CancellationToken cancellationToken) =>
- {
- if (!EndpointHelpers.TrySetCurrentPublicKey(user, accessor, out IResult? error))
- return error!;
-
- string currentPublicKey = accessor.GetFingerprintSha512();
- SyncDeltaResponse delta = await BuildSyncDeltaAsync(
- dbContext,
- currentPublicKey,
- since,
- limit,
- peerFilter: null,
- cancellationToken);
-
- return Results.Ok(delta);
- });
-
- app.Map("/ws/sync", async context =>
- {
- if (!context.WebSockets.IsWebSocketRequest)
- {
- context.Response.StatusCode = StatusCodes.Status400BadRequest;
- await context.Response.WriteAsync("WebSocket request expected.");
- return;
- }
-
- string token = context.Request.Query["access_token"].ToString();
- if (string.IsNullOrWhiteSpace(token))
- {
- context.Response.StatusCode = StatusCodes.Status401Unauthorized;
- await context.Response.WriteAsync("Missing access_token query parameter.");
- return;
- }
-
- TokenValidationParameters tokenValidationParameters = context.RequestServices
- .GetRequiredService();
-
- ClaimsPrincipal? user = ValidateToken(token, tokenValidationParameters);
- string? currentPublicKey = user?.FindFirstValue(ClaimTypes.NameIdentifier);
-
- if (string.IsNullOrWhiteSpace(currentPublicKey))
- {
- context.Response.StatusCode = StatusCodes.Status401Unauthorized;
- await context.Response.WriteAsync("Invalid token.");
- return;
- }
-
- using WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
- DateTime? since = ParseSince(context.Request.Query["since"].ToString());
- int? limit = ParseLimit(context.Request.Query["limit"].ToString());
-
- await StreamSyncWebSocketAsync(
- webSocket,
- currentPublicKey,
- context.RequestServices,
- since,
- limit,
- peerFilter: null,
- context.RequestAborted);
- });
-
- app.Map("/ws/conversations/{peerFingerprint}", async context =>
- {
- if (!context.WebSockets.IsWebSocketRequest)
- {
- context.Response.StatusCode = StatusCodes.Status400BadRequest;
- await context.Response.WriteAsync("WebSocket request expected.");
- return;
- }
-
- string token = context.Request.Query["access_token"].ToString();
- if (string.IsNullOrWhiteSpace(token))
- {
- context.Response.StatusCode = StatusCodes.Status401Unauthorized;
- await context.Response.WriteAsync("Missing access_token query parameter.");
- return;
- }
-
- TokenValidationParameters tokenValidationParameters = context.RequestServices
- .GetRequiredService();
-
- ClaimsPrincipal? user = ValidateToken(token, tokenValidationParameters);
- string? currentPublicKey = user?.FindFirstValue(ClaimTypes.NameIdentifier);
- string? peerFingerprint = context.Request.RouteValues["peerFingerprint"]?.ToString();
-
- if (string.IsNullOrWhiteSpace(currentPublicKey))
- {
- context.Response.StatusCode = StatusCodes.Status401Unauthorized;
- await context.Response.WriteAsync("Invalid token.");
- return;
- }
-
- if (!IsValidFingerprint(peerFingerprint))
- {
- context.Response.StatusCode = StatusCodes.Status400BadRequest;
- await context.Response.WriteAsync("peerFingerprint must be a 128-character hexadecimal string.");
- return;
- }
-
- using WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
- DateTime? since = ParseSince(context.Request.Query["since"].ToString());
- int? limit = ParseLimit(context.Request.Query["limit"].ToString());
-
- await StreamSyncWebSocketAsync(
- webSocket,
- currentPublicKey,
- context.RequestServices,
- since,
- limit,
- peerFingerprint,
- context.RequestAborted);
- });
-
- return group;
- }
-
- private static async Task StreamSyncWebSocketAsync(
- WebSocket webSocket,
- string currentPublicKey,
- IServiceProvider serviceProvider,
- DateTime? initialSince,
- int? limit,
- string? peerFilter,
- CancellationToken cancellationToken)
- {
- SyncNotificationHub syncNotificationHub = serviceProvider.GetRequiredService();
- DateTime cursor = initialSince?.ToUniversalTime() ?? DateTime.UtcNow;
- int boundedLimit = Math.Clamp(limit ?? 700, 1, 1000);
- long lastSeenVersion = syncNotificationHub.GetVersion(currentPublicKey, peerFilter);
-
- cursor = await SendDeltaIfAnyAsync(
- webSocket,
- currentPublicKey,
- serviceProvider,
- peerFilter,
- boundedLimit,
- cursor,
- cancellationToken);
-
- while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
- {
- try
- {
- lastSeenVersion = await syncNotificationHub.WaitForChangeAsync(
- currentPublicKey,
- peerFilter,
- lastSeenVersion,
- cancellationToken);
- }
- catch (OperationCanceledException)
- {
- break;
- }
-
- if (webSocket.State != WebSocketState.Open)
- {
- break;
- }
-
- cursor = await SendDeltaIfAnyAsync(
- webSocket,
- currentPublicKey,
- serviceProvider,
- peerFilter,
- boundedLimit,
- cursor,
- cancellationToken);
- }
- }
-
- private static async Task SendDeltaIfAnyAsync(
- WebSocket webSocket,
- string currentPublicKey,
- IServiceProvider serviceProvider,
- string? peerFilter,
- int limit,
- DateTime cursor,
- CancellationToken cancellationToken)
- {
- using IServiceScope scope = serviceProvider.CreateScope();
- MessagerDbContext dbContext = scope.ServiceProvider.GetRequiredService();
-
- SyncDeltaResponse delta = await BuildSyncDeltaAsync(
- dbContext,
- currentPublicKey,
- cursor,
- limit,
- peerFilter,
- cancellationToken);
-
- if (delta.Messages.Count == 0 && delta.KeyExchanges.Count == 0)
- {
- return cursor;
- }
-
- string responseJson = JsonSerializer.Serialize(new SyncDeltaWebSocketResponse("sync-delta", delta), JsonOptions);
- byte[] responseBytes = Encoding.UTF8.GetBytes(responseJson);
- await webSocket.SendAsync(responseBytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken);
-
- return GetCursorFromDelta(delta, cursor);
- }
-
- private static async Task BuildSyncDeltaAsync(
- MessagerDbContext dbContext,
- string currentPublicKey,
- DateTime? since,
- int? limit,
- string? peerFilter,
- CancellationToken cancellationToken)
- {
- DateTime threshold = since?.ToUniversalTime() ?? DateTime.UnixEpoch;
- int boundedLimit = Math.Clamp(limit ?? 200, 1, 1000);
-
- IQueryable messageQuery = dbContext.Messages
- .Where(x =>
- (x.FromPublicKey == currentPublicKey || x.ToPublicKey == currentPublicKey) &&
- x.CreatedAt > threshold);
-
- IQueryable keyExchangeQuery = dbContext.KeyExchanges
- .Where(x =>
- (x.FromPublicKey == currentPublicKey || x.ToPublicKey == currentPublicKey) &&
- x.CreatedAt > threshold);
-
- if (!string.IsNullOrWhiteSpace(peerFilter))
- {
- messageQuery = messageQuery.Where(x =>
- (x.FromPublicKey == peerFilter || x.ToPublicKey == peerFilter));
-
- keyExchangeQuery = keyExchangeQuery.Where(x =>
- (x.FromPublicKey == peerFilter || x.ToPublicKey == peerFilter));
- }
-
- List messageRecords = await messageQuery
- .OrderBy(x => x.CreatedAt)
- .Take(boundedLimit)
- .ToListAsync(cancellationToken);
-
- List keyExchangeRecords = await keyExchangeQuery
- .OrderBy(x => x.CreatedAt)
- .Take(boundedLimit)
- .ToListAsync(cancellationToken);
-
- if (messageRecords.Count == 0 && keyExchangeRecords.Count == 0)
- {
- return new SyncDeltaResponse(DateTime.UtcNow, [], [], []);
- }
-
- HashSet relatedFingerprints =
- [
- currentPublicKey,
- .. messageRecords.SelectMany(x => new[] { x.FromPublicKey, x.ToPublicKey }),
- .. keyExchangeRecords.SelectMany(x => new[] { x.FromPublicKey, x.ToPublicKey })
- ];
-
- List profiles = await dbContext.PublicKeys
- .Where(x => relatedFingerprints.Contains(x.FingerprintSha512))
- .Select(x => new PublicKeyProfileResponse(
- x.FingerprintSha512,
- x.UserName,
- x.UserTag,
- Convert.ToBase64String(x.Der)))
- .ToListAsync(cancellationToken);
-
- List messages = messageRecords
- .Select(x => new MessageResponse(
- x.FromPublicKey,
- x.ToPublicKey,
- Convert.ToBase64String(x.EncryptedContent),
- x.MessageHash,
- x.CreatedAt))
- .ToList();
-
- List keyExchanges = keyExchangeRecords
- .Select(x => new KeyExchangeResponse(
- x.FromPublicKey,
- x.ToPublicKey,
- Convert.ToBase64String(x.EncryptedPrivateKey),
- x.CreatedAt))
- .ToList();
-
- return new SyncDeltaResponse(DateTime.UtcNow, profiles, keyExchanges, messages);
- }
-
- private static DateTime GetCursorFromDelta(SyncDeltaResponse delta, DateTime fallback)
- {
- DateTime? latestMessage = delta.Messages
- .Select(x => x.CreatedAt.ToUniversalTime())
- .DefaultIfEmpty()
- .Max();
-
- DateTime? latestKeyExchange = delta.KeyExchanges
- .Select(x => x.CreatedAt.ToUniversalTime())
- .DefaultIfEmpty()
- .Max();
-
- DateTime latest = fallback;
-
- if (latestMessage.HasValue && latestMessage.Value > latest)
- {
- latest = latestMessage.Value;
- }
-
- if (latestKeyExchange.HasValue && latestKeyExchange.Value > latest)
- {
- latest = latestKeyExchange.Value;
- }
-
- return latest;
- }
-
- private static DateTime? ParseSince(string raw)
- {
- if (DateTime.TryParse(raw, out DateTime parsed))
- {
- return parsed;
- }
-
- return null;
- }
-
- private static int? ParseLimit(string raw)
- {
- if (int.TryParse(raw, out int parsed))
- {
- return parsed;
- }
-
- return null;
- }
-
- private static bool IsValidFingerprint(string? fingerprint) =>
- fingerprint is not null && FingerprintPattern.IsMatch(fingerprint);
-
- private static ClaimsPrincipal? ValidateToken(string token, TokenValidationParameters tokenValidationParameters)
- {
- try
- {
- JwtSecurityTokenHandler handler = new();
- return handler.ValidateToken(token, tokenValidationParameters, out _);
- }
- catch (SecurityTokenException)
- {
- return null;
- }
- }
-
- private sealed record SyncDeltaWebSocketResponse(string Type, SyncDeltaResponse Payload);
-}
diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs
new file mode 100644
index 0000000..d2e0414
--- /dev/null
+++ b/API/Extensions/ClaimsPrincipalExtensions.cs
@@ -0,0 +1,22 @@
+using System.Security.Claims;
+
+namespace Api.Extensions;
+
+internal static class ClaimsPrincipalExtensions
+{
+ public static string GetFingerprint(this ClaimsPrincipal user)
+ {
+ string? fingerprint = user.FindFirstValue(ClaimTypes.NameIdentifier);
+
+ if (string.IsNullOrWhiteSpace(fingerprint))
+ throw new InvalidOperationException("Fingerprint claim is missing from the token.");
+
+ return fingerprint;
+ }
+
+ public static bool TryGetFingerprint(this ClaimsPrincipal user, out string? fingerprint)
+ {
+ fingerprint = user.FindFirstValue(ClaimTypes.NameIdentifier);
+ return !string.IsNullOrWhiteSpace(fingerprint);
+ }
+}
diff --git a/API/Extensions/DtoMappingExtensions.cs b/API/Extensions/DtoMappingExtensions.cs
new file mode 100644
index 0000000..6593b1f
--- /dev/null
+++ b/API/Extensions/DtoMappingExtensions.cs
@@ -0,0 +1,19 @@
+using Api.Contracts;
+using Application.DTOs;
+
+namespace Api.Extensions;
+
+internal static class DtoMappingExtensions
+{
+ public static MessageResponse ToResponse(this MessageDto dto) =>
+ new(dto.FromPublicKey, dto.ToPublicKey, Convert.ToBase64String(dto.EncryptedContent), dto.MessageHash, dto.CreatedAt);
+
+ public static KeyExchangeResponse ToResponse(this KeyExchangeDto dto) =>
+ new(dto.FromPublicKey, dto.ToPublicKey, Convert.ToBase64String(dto.EncryptedPrivateKey), dto.CreatedAt);
+
+ public static SyncDeltaResponse ToResponse(this SyncDeltaDto dto) => new(
+ dto.ServerTimeUtc,
+ dto.Profiles.Select(p => new PublicKeyProfileResponse(p.FingerprintSha512, p.UserName, p.UserTag, Convert.ToBase64String(p.Der))).ToList(),
+ dto.KeyExchanges.Select(k => k.ToResponse()).ToList(),
+ dto.Messages.Select(m => m.ToResponse()).ToList());
+}
diff --git a/API/Extensions/WebApplicationExtensions.cs b/API/Extensions/WebApplicationExtensions.cs
new file mode 100644
index 0000000..d06e394
--- /dev/null
+++ b/API/Extensions/WebApplicationExtensions.cs
@@ -0,0 +1,27 @@
+using Api.Middleware;
+
+namespace Api.Extensions;
+
+internal static class WebApplicationExtensions
+{
+ public static WebApplication UseApiMiddleware(this WebApplication app)
+ {
+ app.UseExceptionHandler();
+ app.UseCorrelationId();
+ app.UseHttpsRedirection();
+ app.UseRateLimiter();
+ app.UseAuthentication();
+ app.UseAuthorization();
+ app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
+ return app;
+ }
+
+ public static WebApplication MapApiEndpoints(this WebApplication app)
+ {
+ app.MapControllers();
+ return app;
+ }
+
+ private static IApplicationBuilder UseCorrelationId(this IApplicationBuilder app)
+ => app.UseMiddleware();
+}
diff --git a/API/Middleware/AppExceptionHandler.cs b/API/Middleware/AppExceptionHandler.cs
new file mode 100644
index 0000000..50565c2
--- /dev/null
+++ b/API/Middleware/AppExceptionHandler.cs
@@ -0,0 +1,38 @@
+using Api.Contracts;
+using Application.Exceptions;
+using Microsoft.AspNetCore.Diagnostics;
+using FvValidationException = FluentValidation.ValidationException;
+using AppValidationException = Application.Exceptions.ValidationException;
+
+namespace Api.Middleware;
+
+internal sealed class AppExceptionHandler(ILogger logger) : IExceptionHandler
+{
+ public async ValueTask TryHandleAsync(
+ HttpContext context,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ (int statusCode, string message) = exception switch
+ {
+ FvValidationException fv => (StatusCodes.Status400BadRequest, FormatFluentValidationErrors(fv)),
+ NotFoundException ex => (StatusCodes.Status404NotFound, ex.Message),
+ ConflictException ex => (StatusCodes.Status409Conflict, ex.Message),
+ UnauthorizedException ex => (StatusCodes.Status401Unauthorized, ex.Message),
+ AppValidationException ex => (StatusCodes.Status400BadRequest, ex.Message),
+ ArgumentException ex => (StatusCodes.Status400BadRequest, ex.Message),
+ OperationCanceledException => (StatusCodes.Status499ClientClosedRequest, "Request cancelled."),
+ _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.")
+ };
+
+ if (statusCode == StatusCodes.Status500InternalServerError)
+ logger.LogError(exception, "Unhandled exception");
+
+ context.Response.StatusCode = statusCode;
+ await context.Response.WriteAsJsonAsync(new ErrorResponse(message), cancellationToken);
+ return true;
+ }
+
+ private static string FormatFluentValidationErrors(FvValidationException ex) =>
+ string.Join("; ", ex.Errors.Select(e => e.ErrorMessage));
+}
diff --git a/API/Middleware/CorrelationIdMiddleware.cs b/API/Middleware/CorrelationIdMiddleware.cs
new file mode 100644
index 0000000..c13bd80
--- /dev/null
+++ b/API/Middleware/CorrelationIdMiddleware.cs
@@ -0,0 +1,22 @@
+namespace Api.Middleware;
+
+internal sealed class CorrelationIdMiddleware(RequestDelegate next)
+{
+ internal const string HeaderName = "X-Correlation-Id";
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ string correlationId = context.Request.Headers.TryGetValue(HeaderName, out var incoming) && !string.IsNullOrWhiteSpace(incoming)
+ ? incoming.ToString()
+ : Guid.NewGuid().ToString("N");
+
+ context.Items[HeaderName] = correlationId;
+ context.Response.OnStarting(() =>
+ {
+ context.Response.Headers[HeaderName] = correlationId;
+ return Task.CompletedTask;
+ });
+
+ await next(context);
+ }
+}
diff --git a/API/Program.cs b/API/Program.cs
index 64d7b68..52d6a37 100644
--- a/API/Program.cs
+++ b/API/Program.cs
@@ -1,136 +1,66 @@
-using System.Security.Claims;
using System.Net;
-using System.Text;
-using System.Threading.RateLimiting;
-using API.Endpoints;
-using API.Realtime;
-using API.Security;
+using Api;
+using Api.Extensions;
using Application;
using Infrastructure;
using Infrastructure.Persistence;
-using Microsoft.AspNetCore.Authentication.JwtBearer;
-using Microsoft.AspNetCore.RateLimiting;
-using Microsoft.IdentityModel.Tokens;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
string connectionString = builder.Configuration["POSTGRES_CONNECTION_STRING"]
- ?? Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
- ?? throw new InvalidOperationException("POSTGRES_CONNECTION_STRING environment variable is required.");
+ ?? throw new InvalidOperationException("POSTGRES_CONNECTION_STRING is required.");
string jwtSigningKey = builder.Configuration["JWT_SIGNING_KEY"]
- ?? Environment.GetEnvironmentVariable("JWT_SIGNING_KEY")
- ?? throw new InvalidOperationException("JWT_SIGNING_KEY environment variable is required.");
+ ?? throw new InvalidOperationException("JWT_SIGNING_KEY is required.");
-string bindIp = builder.Configuration["API_BIND_IP"]
- ?? Environment.GetEnvironmentVariable("API_BIND_IP")
- ?? "0.0.0.0";
+ConfigureKestrel(builder);
-string bindPortRaw = builder.Configuration["API_BIND_PORT"]
- ?? Environment.GetEnvironmentVariable("API_BIND_PORT")
- ?? "5000";
+builder.Services
+ .RegisterInfrastructureServices(connectionString)
+ .RegisterApplicationServices()
+ .RegisterApiServices(jwtSigningKey);
-if (!int.TryParse(bindPortRaw, out int bindPort) || bindPort is < 1 or > 65535)
- throw new InvalidOperationException("API_BIND_PORT must be a valid TCP port in range 1-65535.");
-
-builder.WebHost.ConfigureKestrel(options =>
-{
- if (bindIp == "*" || bindIp == "+" || bindIp == "0.0.0.0")
- {
- options.ListenAnyIP(bindPort);
- return;
- }
+WebApplication app = builder.Build();
- if (bindIp.Equals("localhost", StringComparison.OrdinalIgnoreCase))
- {
- options.ListenLocalhost(bindPort);
- return;
- }
+await EnsureDatabaseCreatedAsync(app);
- if (!IPAddress.TryParse(bindIp, out IPAddress? ipAddress))
- throw new InvalidOperationException("API_BIND_IP must be a valid IP address, localhost, *, or +.");
+app.UseApiMiddleware()
+ .MapApiEndpoints();
- options.Listen(ipAddress, bindPort);
-});
+await app.RunAsync();
-byte[] jwtKeyBytes = Encoding.UTF8.GetBytes(jwtSigningKey);
-TokenValidationParameters tokenValidationParameters = new()
+static void ConfigureKestrel(WebApplicationBuilder builder)
{
- ValidateIssuer = true,
- ValidIssuer = JwtTokenIssuer.Issuer,
- ValidateAudience = true,
- ValidAudience = JwtTokenIssuer.Audience,
- ValidateIssuerSigningKey = true,
- IssuerSigningKey = new SymmetricSecurityKey(jwtKeyBytes),
- ValidateLifetime = true,
- ClockSkew = TimeSpan.FromSeconds(30),
- NameClaimType = ClaimTypes.NameIdentifier
-};
-
-builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
- .AddJwtBearer(options =>
- {
- options.TokenValidationParameters = tokenValidationParameters;
- });
-
-builder.Services.AddAuthorization();
-builder.Services.AddSingleton(new JwtTokenIssuer(jwtSigningKey));
-builder.Services.AddSingleton(tokenValidationParameters);
-builder.Services.AddSingleton();
+ string bindIp = builder.Configuration["API_BIND_IP"] ?? "0.0.0.0";
+ string bindPortRaw = builder.Configuration["API_BIND_PORT"] ?? "5000";
-builder.Services.AddRateLimiter(options =>
-{
- options.AddSlidingWindowLimiter("auth", limiterOptions =>
- {
- limiterOptions.PermitLimit = 10;
- limiterOptions.Window = TimeSpan.FromMinutes(1);
- limiterOptions.SegmentsPerWindow = 6;
- limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
- limiterOptions.QueueLimit = 0;
- });
+ if (!int.TryParse(bindPortRaw, out int bindPort) || bindPort is < 1 or > 65535)
+ throw new InvalidOperationException("API_BIND_PORT must be a valid TCP port (1–65535).");
- options.AddSlidingWindowLimiter("search", limiterOptions =>
+ builder.WebHost.ConfigureKestrel(options =>
{
- limiterOptions.PermitLimit = 30;
- limiterOptions.Window = TimeSpan.FromMinutes(1);
- limiterOptions.SegmentsPerWindow = 6;
- limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
- limiterOptions.QueueLimit = 0;
+ if (bindIp is "*" or "+" or "0.0.0.0")
+ {
+ options.ListenAnyIP(bindPort);
+ return;
+ }
+
+ if (bindIp.Equals("localhost", StringComparison.OrdinalIgnoreCase))
+ {
+ options.ListenLocalhost(bindPort);
+ return;
+ }
+
+ if (!IPAddress.TryParse(bindIp, out IPAddress? ipAddress))
+ throw new InvalidOperationException("API_BIND_IP must be a valid IP address, localhost, *, or +.");
+
+ options.Listen(ipAddress, bindPort);
});
+}
- options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
-});
-
-builder.Services.AddInfrastructure(connectionString);
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-
-WebApplication app = builder.Build();
-
-using (IServiceScope scope = app.Services.CreateScope())
+static async Task EnsureDatabaseCreatedAsync(WebApplication app)
{
+ await using AsyncServiceScope scope = app.Services.CreateAsyncScope();
MessagerDbContext dbContext = scope.ServiceProvider.GetRequiredService();
- dbContext.Database.EnsureCreated();
+ await dbContext.Database.EnsureCreatedAsync();
}
-
-app.UseHttpsRedirection();
-app.UseRateLimiter();
-app.UseAuthentication();
-app.UseAuthorization();
-app.UseWebSockets(new WebSocketOptions
-{
- KeepAliveInterval = TimeSpan.FromSeconds(30)
-});
-
-app.MapAuthEndpoints();
-app.MapMessageEndpoints();
-app.MapKeyExchangeEndpoints();
-app.MapPublicKeyEndpoints();
-app.MapSyncEndpoints();
-
-app.Run();
diff --git a/API/Realtime/SyncNotificationHub.cs b/API/Realtime/SyncNotificationHub.cs
index ca7de11..18b36f6 100644
--- a/API/Realtime/SyncNotificationHub.cs
+++ b/API/Realtime/SyncNotificationHub.cs
@@ -1,7 +1,7 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
-namespace API.Realtime;
+namespace Api.Realtime;
internal sealed class SyncNotificationHub
{
@@ -22,8 +22,7 @@ private sealed class StreamState
public long GetVersion(string ownerFingerprint, string? peerFingerprint)
{
- string streamKey = BuildStreamKey(ownerFingerprint, peerFingerprint);
- StreamState state = GetOrCreateState(streamKey);
+ StreamState state = GetOrCreateState(BuildStreamKey(ownerFingerprint, peerFingerprint));
return Volatile.Read(ref state.Version);
}
@@ -33,75 +32,52 @@ public async ValueTask WaitForChangeAsync(
long lastSeenVersion,
CancellationToken cancellationToken)
{
- string streamKey = BuildStreamKey(ownerFingerprint, peerFingerprint);
- StreamState state = GetOrCreateState(streamKey);
+ StreamState state = GetOrCreateState(BuildStreamKey(ownerFingerprint, peerFingerprint));
- long currentVersion = Volatile.Read(ref state.Version);
- if (currentVersion > lastSeenVersion)
- {
- return currentVersion;
- }
+ long current = Volatile.Read(ref state.Version);
+ if (current > lastSeenVersion)
+ return current;
while (await state.SignalChannel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
- while (state.SignalChannel.Reader.TryRead(out long notifiedVersion))
+ while (state.SignalChannel.Reader.TryRead(out long notified))
{
- if (notifiedVersion > lastSeenVersion)
- {
- return notifiedVersion;
- }
+ if (notified > lastSeenVersion)
+ return notified;
}
- currentVersion = Volatile.Read(ref state.Version);
- if (currentVersion > lastSeenVersion)
- {
- return currentVersion;
- }
+ current = Volatile.Read(ref state.Version);
+ if (current > lastSeenVersion)
+ return current;
}
return lastSeenVersion;
}
- public void NotifyMessage(string fromPublicKey, string toPublicKey)
- {
+ public void NotifyMessage(string fromPublicKey, string toPublicKey) =>
NotifyPair(fromPublicKey, toPublicKey);
- }
- public void NotifyKeyExchange(string fromPublicKey, string toPublicKey)
- {
+ public void NotifyKeyExchange(string fromPublicKey, string toPublicKey) =>
NotifyPair(fromPublicKey, toPublicKey);
- }
- private void NotifyPair(string fromPublicKey, string toPublicKey)
+ private void NotifyPair(string from, string to)
{
- Notify(fromPublicKey, null);
- Notify(toPublicKey, null);
-
- Notify(fromPublicKey, toPublicKey);
- Notify(toPublicKey, fromPublicKey);
+ Notify(from, null);
+ Notify(to, null);
+ Notify(from, to);
+ Notify(to, from);
}
- private void Notify(string ownerFingerprint, string? peerFingerprint)
+ private void Notify(string owner, string? peer)
{
- string streamKey = BuildStreamKey(ownerFingerprint, peerFingerprint);
- StreamState state = GetOrCreateState(streamKey);
-
- long nextVersion = Interlocked.Increment(ref state.Version);
- state.SignalChannel.Writer.TryWrite(nextVersion);
- }
-
- private StreamState GetOrCreateState(string streamKey)
- {
- return _streams.GetOrAdd(streamKey, _ => new StreamState());
+ StreamState state = GetOrCreateState(BuildStreamKey(owner, peer));
+ long next = Interlocked.Increment(ref state.Version);
+ state.SignalChannel.Writer.TryWrite(next);
}
- private static string BuildStreamKey(string ownerFingerprint, string? peerFingerprint)
- {
- if (string.IsNullOrWhiteSpace(peerFingerprint))
- {
- return $"inbox:{ownerFingerprint}";
- }
+ private StreamState GetOrCreateState(string key) =>
+ _streams.GetOrAdd(key, _ => new StreamState());
- return $"conversation:{ownerFingerprint}|{peerFingerprint}";
- }
+ private static string BuildStreamKey(string owner, string? peer) =>
+ peer is null ? $"inbox:{owner}" : $"conversation:{owner}|{peer}";
}
diff --git a/API/Realtime/SyncNotificationHubAdapter.cs b/API/Realtime/SyncNotificationHubAdapter.cs
new file mode 100644
index 0000000..63cd626
--- /dev/null
+++ b/API/Realtime/SyncNotificationHubAdapter.cs
@@ -0,0 +1,12 @@
+using Application.Interfaces;
+
+namespace Api.Realtime;
+
+internal sealed class SyncNotificationHubAdapter(SyncNotificationHub hub) : ISyncNotifier
+{
+ public void NotifyMessage(string fromPublicKey, string toPublicKey) =>
+ hub.NotifyMessage(fromPublicKey, toPublicKey);
+
+ public void NotifyKeyExchange(string fromPublicKey, string toPublicKey) =>
+ hub.NotifyKeyExchange(fromPublicKey, toPublicKey);
+}
diff --git a/API/Security/JwtTokenIssuer.cs b/API/Security/JwtTokenIssuer.cs
index 0ec2cb2..0b3decd 100644
--- a/API/Security/JwtTokenIssuer.cs
+++ b/API/Security/JwtTokenIssuer.cs
@@ -3,9 +3,9 @@
using System.Text;
using Microsoft.IdentityModel.Tokens;
-namespace API.Security;
+namespace Api.Security;
-internal sealed class JwtTokenIssuer(string signingKey)
+public sealed class JwtTokenIssuer(string signingKey)
{
public const string Issuer = "messager-api";
public const string Audience = "messager-client";
@@ -16,11 +16,6 @@ internal sealed class JwtTokenIssuer(string signingKey)
{
DateTime expiresAtUtc = DateTime.UtcNow.AddHours(12);
- List claims =
- [
- new(ClaimTypes.NameIdentifier, fingerprintSha512)
- ];
-
SigningCredentials credentials = new(
new SymmetricSecurityKey(_keyBytes),
SecurityAlgorithms.HmacSha256);
@@ -28,11 +23,10 @@ internal sealed class JwtTokenIssuer(string signingKey)
JwtSecurityToken jwt = new(
issuer: Issuer,
audience: Audience,
- claims: claims,
+ claims: [new Claim(ClaimTypes.NameIdentifier, fingerprintSha512)],
expires: expiresAtUtc,
signingCredentials: credentials);
- string token = new JwtSecurityTokenHandler().WriteToken(jwt);
- return (token, expiresAtUtc);
+ return (new JwtSecurityTokenHandler().WriteToken(jwt), expiresAtUtc);
}
}
diff --git a/Application/Application.csproj b/Application/Application.csproj
index fa68a1d..cdf86c1 100644
--- a/Application/Application.csproj
+++ b/Application/Application.csproj
@@ -10,4 +10,10 @@
+
+
+
+
+
+
diff --git a/Application/Behaviors/ValidationBehavior.cs b/Application/Behaviors/ValidationBehavior.cs
new file mode 100644
index 0000000..117c5fe
--- /dev/null
+++ b/Application/Behaviors/ValidationBehavior.cs
@@ -0,0 +1,28 @@
+using FluentValidation;
+using MediatR;
+
+namespace Application.Behaviors;
+
+public sealed class ValidationBehavior(IEnumerable> validators)
+ : IPipelineBehavior
+ where TRequest : IRequest
+{
+ public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken)
+ {
+ if (!validators.Any())
+ return await next(cancellationToken);
+
+ ValidationContext context = new(request);
+
+ IEnumerable failures = (await Task.WhenAll(
+ validators.Select(v => v.ValidateAsync(context, cancellationToken))))
+ .SelectMany(r => r.Errors)
+ .Where(f => f is not null)
+ .ToList();
+
+ if (failures.Any())
+ throw new ValidationException(failures);
+
+ return await next(cancellationToken);
+ }
+}
diff --git a/Application/Commands/GetLoginChallengeCommand.cs b/Application/Commands/GetLoginChallengeCommand.cs
new file mode 100644
index 0000000..5949087
--- /dev/null
+++ b/Application/Commands/GetLoginChallengeCommand.cs
@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Commands;
+
+public sealed record GetLoginChallengeCommand(string FingerprintSha512)
+ : IRequest;
diff --git a/Application/Commands/LoginCommand.cs b/Application/Commands/LoginCommand.cs
new file mode 100644
index 0000000..a7701e1
--- /dev/null
+++ b/Application/Commands/LoginCommand.cs
@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Commands;
+
+public sealed record LoginCommand(string FingerprintSha512, string ChallengeBase64, string SignatureBase64)
+ : IRequest;
diff --git a/Application/Commands/RegisterCommand.cs b/Application/Commands/RegisterCommand.cs
new file mode 100644
index 0000000..1c7c986
--- /dev/null
+++ b/Application/Commands/RegisterCommand.cs
@@ -0,0 +1,8 @@
+using MediatR;
+
+namespace Application.Commands;
+
+public sealed record RegisterCommand(string DerBase64, string UserName, uint UserTag)
+ : IRequest;
+
+public sealed record RegisterResult(string FingerprintSha512, string UserName, uint UserTag);
diff --git a/Application/Commands/SendKeyExchangeCommand.cs b/Application/Commands/SendKeyExchangeCommand.cs
new file mode 100644
index 0000000..90e5d84
--- /dev/null
+++ b/Application/Commands/SendKeyExchangeCommand.cs
@@ -0,0 +1,9 @@
+using Application.DTOs;
+using MediatR;
+
+namespace Application.Commands;
+
+public sealed record SendKeyExchangeCommand(
+ string CurrentUserFingerprint,
+ string ToPublicKey,
+ string EncryptedPrivateKeyBase64) : IRequest;
diff --git a/Application/Commands/SendMessageCommand.cs b/Application/Commands/SendMessageCommand.cs
new file mode 100644
index 0000000..500cf0e
--- /dev/null
+++ b/Application/Commands/SendMessageCommand.cs
@@ -0,0 +1,10 @@
+using Application.DTOs;
+using MediatR;
+
+namespace Application.Commands;
+
+public sealed record SendMessageCommand(
+ string CurrentUserFingerprint,
+ string ToPublicKey,
+ string EncryptedContentBase64,
+ string MessageHash) : IRequest;
diff --git a/Application/DTOs/KeyExchangeDto.cs b/Application/DTOs/KeyExchangeDto.cs
new file mode 100644
index 0000000..3d4f055
--- /dev/null
+++ b/Application/DTOs/KeyExchangeDto.cs
@@ -0,0 +1,7 @@
+namespace Application.DTOs;
+
+public sealed record KeyExchangeDto(
+ string FromPublicKey,
+ string ToPublicKey,
+ byte[] EncryptedPrivateKey,
+ DateTime CreatedAt);
diff --git a/Application/DTOs/MessageDto.cs b/Application/DTOs/MessageDto.cs
new file mode 100644
index 0000000..6c03821
--- /dev/null
+++ b/Application/DTOs/MessageDto.cs
@@ -0,0 +1,8 @@
+namespace Application.DTOs;
+
+public sealed record MessageDto(
+ string FromPublicKey,
+ string ToPublicKey,
+ byte[] EncryptedContent,
+ string MessageHash,
+ DateTime CreatedAt);
diff --git a/Application/DTOs/PublicKeyProfileDto.cs b/Application/DTOs/PublicKeyProfileDto.cs
new file mode 100644
index 0000000..d58f284
--- /dev/null
+++ b/Application/DTOs/PublicKeyProfileDto.cs
@@ -0,0 +1,7 @@
+namespace Application.DTOs;
+
+public sealed record PublicKeyProfileDto(
+ string FingerprintSha512,
+ string UserName,
+ uint UserTag,
+ byte[] Der);
diff --git a/Application/DTOs/SyncDeltaDto.cs b/Application/DTOs/SyncDeltaDto.cs
new file mode 100644
index 0000000..439a6da
--- /dev/null
+++ b/Application/DTOs/SyncDeltaDto.cs
@@ -0,0 +1,7 @@
+namespace Application.DTOs;
+
+public sealed record SyncDeltaDto(
+ DateTime ServerTimeUtc,
+ IReadOnlyList Profiles,
+ IReadOnlyList KeyExchanges,
+ IReadOnlyList Messages);
diff --git a/Application/DependencyInjection.cs b/Application/DependencyInjection.cs
new file mode 100644
index 0000000..69f86b6
--- /dev/null
+++ b/Application/DependencyInjection.cs
@@ -0,0 +1,24 @@
+using Application.Behaviors;
+using Application.Handlers.Auth;
+using FluentValidation;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Application;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection RegisterApplicationServices(this IServiceCollection services)
+ {
+ services.AddMediatR(cfg =>
+ {
+ cfg.RegisterServicesFromAssembly(typeof(RegisterCommandHandler).Assembly);
+ cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
+ });
+
+ ValidatorOptions.Global.LanguageManager.Enabled = false;
+ services.AddValidatorsFromAssembly(typeof(RegisterCommandHandler).Assembly);
+
+ return services;
+ }
+}
diff --git a/Application/Exceptions/AppException.cs b/Application/Exceptions/AppException.cs
new file mode 100644
index 0000000..2b87fbe
--- /dev/null
+++ b/Application/Exceptions/AppException.cs
@@ -0,0 +1,3 @@
+namespace Application.Exceptions;
+
+public abstract class AppException(string message) : Exception(message);
diff --git a/Application/Exceptions/ConflictException.cs b/Application/Exceptions/ConflictException.cs
new file mode 100644
index 0000000..d4d5354
--- /dev/null
+++ b/Application/Exceptions/ConflictException.cs
@@ -0,0 +1,3 @@
+namespace Application.Exceptions;
+
+public sealed class ConflictException(string message) : AppException(message);
diff --git a/Application/Exceptions/NotFoundException.cs b/Application/Exceptions/NotFoundException.cs
new file mode 100644
index 0000000..5a6d37b
--- /dev/null
+++ b/Application/Exceptions/NotFoundException.cs
@@ -0,0 +1,3 @@
+namespace Application.Exceptions;
+
+public sealed class NotFoundException(string message) : AppException(message);
diff --git a/Application/Exceptions/UnauthorizedException.cs b/Application/Exceptions/UnauthorizedException.cs
new file mode 100644
index 0000000..4dd0721
--- /dev/null
+++ b/Application/Exceptions/UnauthorizedException.cs
@@ -0,0 +1,3 @@
+namespace Application.Exceptions;
+
+public sealed class UnauthorizedException(string message) : AppException(message);
diff --git a/Application/Exceptions/ValidationException.cs b/Application/Exceptions/ValidationException.cs
new file mode 100644
index 0000000..76bff94
--- /dev/null
+++ b/Application/Exceptions/ValidationException.cs
@@ -0,0 +1,3 @@
+namespace Application.Exceptions;
+
+public sealed class ValidationException(string message) : AppException(message);
diff --git a/Application/Handlers/Auth/GetLoginChallengeCommandHandler.cs b/Application/Handlers/Auth/GetLoginChallengeCommandHandler.cs
new file mode 100644
index 0000000..2a079f0
--- /dev/null
+++ b/Application/Handlers/Auth/GetLoginChallengeCommandHandler.cs
@@ -0,0 +1,20 @@
+using Application.Commands;
+using Application.Exceptions;
+using Application.Interfaces;
+using MediatR;
+
+namespace Application.Handlers.Auth;
+
+public sealed class GetLoginChallengeCommandHandler(
+ IPublicKeyRepository publicKeyRepository,
+ ILoginChallengeService loginChallengeService) : IRequestHandler
+{
+ public async Task Handle(GetLoginChallengeCommand request, CancellationToken cancellationToken)
+ {
+ bool exists = await publicKeyRepository.ExistsAsync(request.FingerprintSha512, cancellationToken);
+ if (!exists)
+ throw new NotFoundException($"Public key '{request.FingerprintSha512[..8]}...' not found.");
+
+ return await loginChallengeService.CreateChallengeAsync(request.FingerprintSha512, cancellationToken);
+ }
+}
diff --git a/Application/Handlers/Auth/LoginCommandHandler.cs b/Application/Handlers/Auth/LoginCommandHandler.cs
new file mode 100644
index 0000000..74fb930
--- /dev/null
+++ b/Application/Handlers/Auth/LoginCommandHandler.cs
@@ -0,0 +1,31 @@
+using Application.Commands;
+using Application.Exceptions;
+using Application.Interfaces;
+using MediatR;
+
+namespace Application.Handlers.Auth;
+
+public sealed class LoginCommandHandler(ILoginService loginService) : IRequestHandler
+{
+ public async Task Handle(LoginCommand request, CancellationToken cancellationToken)
+ {
+ byte[] challenge;
+ byte[] signature;
+
+ try
+ {
+ challenge = Convert.FromBase64String(request.ChallengeBase64);
+ signature = Convert.FromBase64String(request.SignatureBase64);
+ }
+ catch (FormatException ex)
+ {
+ throw new ValidationException($"ChallengeBase64 or SignatureBase64 is not valid base64: {ex.Message}");
+ }
+
+ await loginService.ValidateAndConsumeAsync(
+ request.FingerprintSha512,
+ challenge,
+ signature,
+ cancellationToken);
+ }
+}
diff --git a/Application/Handlers/Auth/RegisterCommandHandler.cs b/Application/Handlers/Auth/RegisterCommandHandler.cs
new file mode 100644
index 0000000..80de662
--- /dev/null
+++ b/Application/Handlers/Auth/RegisterCommandHandler.cs
@@ -0,0 +1,38 @@
+using Application.Commands;
+using Application.Exceptions;
+using Application.Interfaces;
+using Domain;
+using MediatR;
+
+namespace Application.Handlers.Auth;
+
+public sealed class RegisterCommandHandler(
+ IPublicKeySecurityService securityService,
+ IPublicKeyRepository publicKeyRepository) : IRequestHandler
+{
+ public async Task Handle(RegisterCommand request, CancellationToken cancellationToken)
+ {
+ byte[] der;
+ try
+ {
+ der = Convert.FromBase64String(request.DerBase64);
+ }
+ catch (FormatException ex)
+ {
+ throw new ValidationException($"DerBase64 is not valid base64: {ex.Message}");
+ }
+
+ securityService.EnsureValidRsaPublicKey(der);
+
+ string fingerprint = securityService.ComputeFingerprintSha512(der);
+
+ bool exists = await publicKeyRepository.ExistsAsync(fingerprint, cancellationToken);
+ if (exists)
+ throw new ConflictException("Public key is already registered.");
+
+ PublicKey publicKey = new(der, fingerprint, request.UserName, request.UserTag);
+ await publicKeyRepository.AddAsync(publicKey, cancellationToken);
+
+ return new RegisterResult(publicKey.FingerprintSha512, publicKey.UserName, publicKey.UserTag);
+ }
+}
diff --git a/Application/Handlers/GetKeyExchangesHandler.cs b/Application/Handlers/GetKeyExchangesHandler.cs
deleted file mode 100644
index 6acb091..0000000
--- a/Application/Handlers/GetKeyExchangesHandler.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Domain;
-
-namespace Application;
-
-public sealed class GetKeyExchangesHandler(
- ICurrentPublicKey currentPublicKey,
- IPublicKeyRepository publicKeyRepository)
-{
- private readonly ICurrentPublicKey _currentPublicKey = currentPublicKey;
- private readonly IPublicKeyRepository _publicKeyRepository = publicKeyRepository;
-
- public IReadOnlyList Handle(
- string toPublicKey,
- DateTime? fromDate = null,
- DateTime? toDate = null)
- {
- ArgumentException.ThrowIfNullOrEmpty(toPublicKey);
-
- PublicKey publicKey = _publicKeyRepository.GetRequired(_currentPublicKey.GetFingerprintSha512());
- return publicKey.GetKeyExchanges(toPublicKey, fromDate, toDate);
- }
-}
diff --git a/Application/Handlers/GetLoginChallengeHandler.cs b/Application/Handlers/GetLoginChallengeHandler.cs
deleted file mode 100644
index 45281eb..0000000
--- a/Application/Handlers/GetLoginChallengeHandler.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace Application;
-
-public sealed class GetLoginChallengeHandler(ILoginChallengeService loginChallengeService)
-{
- private readonly ILoginChallengeService _loginChallengeService = loginChallengeService;
-
- public byte[] Handle(string fingerprintSha512)
- {
- ArgumentException.ThrowIfNullOrEmpty(fingerprintSha512);
-
- return _loginChallengeService.GetChallenge(fingerprintSha512);
- }
-}
diff --git a/Application/Handlers/GetMessagesHandler.cs b/Application/Handlers/GetMessagesHandler.cs
deleted file mode 100644
index 80b3890..0000000
--- a/Application/Handlers/GetMessagesHandler.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Domain;
-
-namespace Application;
-
-public sealed class GetMessagesHandler(
- ICurrentPublicKey currentPublicKey,
- IPublicKeyRepository publicKeyRepository)
-{
- private readonly ICurrentPublicKey _currentPublicKey = currentPublicKey;
- private readonly IPublicKeyRepository _publicKeyRepository = publicKeyRepository;
-
- public IReadOnlyList Handle(
- string toPublicKey,
- DateTime? fromDate = null,
- DateTime? toDate = null)
- {
- ArgumentException.ThrowIfNullOrEmpty(toPublicKey);
-
- PublicKey publicKey = _publicKeyRepository.GetRequired(_currentPublicKey.GetFingerprintSha512());
- return publicKey.GetMessages(toPublicKey, fromDate, toDate);
- }
-}
diff --git a/Application/Handlers/KeyExchanges/GetKeyExchangesQueryHandler.cs b/Application/Handlers/KeyExchanges/GetKeyExchangesQueryHandler.cs
new file mode 100644
index 0000000..1583dc7
--- /dev/null
+++ b/Application/Handlers/KeyExchanges/GetKeyExchangesQueryHandler.cs
@@ -0,0 +1,18 @@
+using Application.DTOs;
+using Application.Interfaces;
+using Application.Queries;
+using MediatR;
+
+namespace Application.Handlers.KeyExchanges;
+
+public sealed class GetKeyExchangesQueryHandler(IKeyExchangeRepository keyExchangeRepository)
+ : IRequestHandler>
+{
+ public Task> Handle(GetKeyExchangesQuery request, CancellationToken cancellationToken) =>
+ keyExchangeRepository.GetConversationAsync(
+ request.CurrentUserFingerprint,
+ request.PeerPublicKey,
+ request.FromDate,
+ request.ToDate,
+ cancellationToken);
+}
diff --git a/Application/Handlers/KeyExchanges/SendKeyExchangeCommandHandler.cs b/Application/Handlers/KeyExchanges/SendKeyExchangeCommandHandler.cs
new file mode 100644
index 0000000..d5f5277
--- /dev/null
+++ b/Application/Handlers/KeyExchanges/SendKeyExchangeCommandHandler.cs
@@ -0,0 +1,37 @@
+using Application.Commands;
+using Application.DTOs;
+using Application.Exceptions;
+using Application.Interfaces;
+using Domain;
+using MediatR;
+
+namespace Application.Handlers.KeyExchanges;
+
+public sealed class SendKeyExchangeCommandHandler(
+ IKeyExchangeRepository keyExchangeRepository,
+ ISyncNotifier syncNotifier) : IRequestHandler
+{
+ public async Task Handle(SendKeyExchangeCommand request, CancellationToken cancellationToken)
+ {
+ byte[] encryptedPrivateKey;
+ try
+ {
+ encryptedPrivateKey = Convert.FromBase64String(request.EncryptedPrivateKeyBase64);
+ }
+ catch (FormatException ex)
+ {
+ throw new ValidationException($"EncryptedPrivateKeyBase64 is not valid base64: {ex.Message}");
+ }
+
+ KeyExchange keyExchange = new(request.CurrentUserFingerprint, request.ToPublicKey, encryptedPrivateKey);
+ await keyExchangeRepository.AddOrUpdateAsync(keyExchange, cancellationToken);
+
+ syncNotifier.NotifyKeyExchange(keyExchange.FromPublicKey, keyExchange.ToPublicKey);
+
+ return new KeyExchangeDto(
+ keyExchange.FromPublicKey,
+ keyExchange.ToPublicKey,
+ keyExchange.EncryptedPrivateKey,
+ keyExchange.CreatedAt);
+ }
+}
diff --git a/Application/Handlers/LoginHandler.cs b/Application/Handlers/LoginHandler.cs
deleted file mode 100644
index 5fcc478..0000000
--- a/Application/Handlers/LoginHandler.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace Application;
-
-public sealed class LoginHandler(ILoginService loginService)
-{
- private readonly ILoginService _loginService = loginService;
-
- public string Handle(string fingerprintSha512, byte[] challenge, byte[] signature)
- {
- ArgumentException.ThrowIfNullOrEmpty(fingerprintSha512);
- ArgumentNullException.ThrowIfNull(challenge);
- ArgumentNullException.ThrowIfNull(signature);
-
- return _loginService.Login(fingerprintSha512, challenge, signature);
- }
-}
diff --git a/Application/Handlers/Messages/GetMessagesQueryHandler.cs b/Application/Handlers/Messages/GetMessagesQueryHandler.cs
new file mode 100644
index 0000000..df0c43e
--- /dev/null
+++ b/Application/Handlers/Messages/GetMessagesQueryHandler.cs
@@ -0,0 +1,18 @@
+using Application.DTOs;
+using Application.Interfaces;
+using Application.Queries;
+using MediatR;
+
+namespace Application.Handlers.Messages;
+
+public sealed class GetMessagesQueryHandler(IMessageRepository messageRepository)
+ : IRequestHandler>
+{
+ public Task> Handle(GetMessagesQuery request, CancellationToken cancellationToken) =>
+ messageRepository.GetConversationAsync(
+ request.CurrentUserFingerprint,
+ request.PeerPublicKey,
+ request.FromDate,
+ request.ToDate,
+ cancellationToken);
+}
diff --git a/Application/Handlers/Messages/SendMessageCommandHandler.cs b/Application/Handlers/Messages/SendMessageCommandHandler.cs
new file mode 100644
index 0000000..9aebdb4
--- /dev/null
+++ b/Application/Handlers/Messages/SendMessageCommandHandler.cs
@@ -0,0 +1,47 @@
+using Application.Commands;
+using Application.DTOs;
+using Application.Exceptions;
+using Application.Interfaces;
+using Domain;
+using MediatR;
+
+namespace Application.Handlers.Messages;
+
+public sealed class SendMessageCommandHandler(
+ IKeyExchangeRepository keyExchangeRepository,
+ IMessageRepository messageRepository,
+ ISyncNotifier syncNotifier) : IRequestHandler
+{
+ public async Task Handle(SendMessageCommand request, CancellationToken cancellationToken)
+ {
+ byte[] encryptedContent;
+ try
+ {
+ encryptedContent = Convert.FromBase64String(request.EncryptedContentBase64);
+ }
+ catch (FormatException ex)
+ {
+ throw new ValidationException($"EncryptedContentBase64 is not valid base64: {ex.Message}");
+ }
+
+ bool hasKeyExchange = await keyExchangeRepository.ExistsAsync(
+ request.CurrentUserFingerprint,
+ request.ToPublicKey,
+ cancellationToken);
+
+ if (!hasKeyExchange)
+ throw new ValidationException("Cannot send a message without first establishing a key exchange with the recipient.");
+
+ Message message = new(request.CurrentUserFingerprint, request.ToPublicKey, encryptedContent, request.MessageHash);
+ await messageRepository.AddAsync(message, cancellationToken);
+
+ syncNotifier.NotifyMessage(message.FromPublicKey, message.ToPublicKey);
+
+ return new MessageDto(
+ message.FromPublicKey,
+ message.ToPublicKey,
+ message.EncryptedContent,
+ message.MessageHash,
+ message.CreatedAt);
+ }
+}
diff --git a/Application/Handlers/PublicKeys/SearchPublicKeysQueryHandler.cs b/Application/Handlers/PublicKeys/SearchPublicKeysQueryHandler.cs
new file mode 100644
index 0000000..c5ced7b
--- /dev/null
+++ b/Application/Handlers/PublicKeys/SearchPublicKeysQueryHandler.cs
@@ -0,0 +1,30 @@
+using Application.DTOs;
+using Application.Exceptions;
+using Application.Interfaces;
+using Application.Queries;
+using MediatR;
+
+namespace Application.Handlers.PublicKeys;
+
+public sealed class SearchPublicKeysQueryHandler(IPublicKeyRepository publicKeyRepository)
+ : IRequestHandler>
+{
+ private const int DefaultLimit = 25;
+ private const int MaxLimit = 100;
+
+ public async Task> Handle(SearchPublicKeysQuery request, CancellationToken cancellationToken)
+ {
+ string normalized = request.UserName.Trim();
+ if (normalized.Length < 2)
+ throw new ValidationException("UserName must have at least 2 characters.");
+
+ int limit = Math.Clamp(request.Limit ?? DefaultLimit, 1, MaxLimit);
+
+ string escaped = normalized
+ .Replace("\\", "\\\\")
+ .Replace("%", "\\%")
+ .Replace("_", "\\_");
+
+ return await publicKeyRepository.SearchAsync(escaped, request.UserTag, limit, cancellationToken);
+ }
+}
diff --git a/Application/Handlers/RegisterHandler.cs b/Application/Handlers/RegisterHandler.cs
deleted file mode 100644
index d9ec74f..0000000
--- a/Application/Handlers/RegisterHandler.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using Domain;
-
-namespace Application;
-
-public sealed class RegisterHandler(IPublicKeySecurityService publicKeySecurityService)
-{
- private readonly IPublicKeySecurityService _publicKeySecurityService = publicKeySecurityService;
-
- public PublicKey Handle(
- byte[] der,
- string userName,
- uint userTag)
- {
- ArgumentNullException.ThrowIfNull(der);
- ArgumentException.ThrowIfNullOrEmpty(userName);
-
- _publicKeySecurityService.EnsureValidRsaPublicKey(der);
- string fingerprintSha512 = _publicKeySecurityService.ComputeFingerprintSha512(der);
-
- return new PublicKey(
- der,
- fingerprintSha512,
- userName,
- userTag
- );
- }
-}
diff --git a/Application/Handlers/SendKeyExchangeHandler.cs b/Application/Handlers/SendKeyExchangeHandler.cs
deleted file mode 100644
index 2ac3a8a..0000000
--- a/Application/Handlers/SendKeyExchangeHandler.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using Domain;
-
-namespace Application;
-
-public sealed class SendKeyExchangeHandler(ICurrentPublicKey currentPublicKey)
-{
- private readonly ICurrentPublicKey _currentPublicKey = currentPublicKey;
-
- public KeyExchange Handle(string toPublicKey, byte[] encryptedPrivateKey)
- {
- ArgumentException.ThrowIfNullOrEmpty(toPublicKey);
- ArgumentNullException.ThrowIfNull(encryptedPrivateKey);
-
- return new KeyExchange(_currentPublicKey.GetFingerprintSha512(), toPublicKey, encryptedPrivateKey);
- }
-}
diff --git a/Application/Handlers/SendMessageHandler.cs b/Application/Handlers/SendMessageHandler.cs
deleted file mode 100644
index 7072e57..0000000
--- a/Application/Handlers/SendMessageHandler.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Domain;
-
-namespace Application;
-
-public sealed class SendMessageHandler(ICurrentPublicKey currentPublicKey)
-{
- private readonly ICurrentPublicKey _currentPublicKey = currentPublicKey;
-
- public Message Handle(string toPublicKey, byte[] encryptedContent, string messageHash)
- {
- ArgumentException.ThrowIfNullOrEmpty(toPublicKey);
- ArgumentNullException.ThrowIfNull(encryptedContent);
- ArgumentException.ThrowIfNullOrEmpty(messageHash);
-
- return new Message(
- _currentPublicKey.GetFingerprintSha512(),
- toPublicKey,
- encryptedContent,
- messageHash
- );
- }
-}
diff --git a/Application/Handlers/Sync/GetSyncDeltaQueryHandler.cs b/Application/Handlers/Sync/GetSyncDeltaQueryHandler.cs
new file mode 100644
index 0000000..0d92a2c
--- /dev/null
+++ b/Application/Handlers/Sync/GetSyncDeltaQueryHandler.cs
@@ -0,0 +1,42 @@
+using Application.DTOs;
+using Application.Interfaces;
+using Application.Queries;
+using MediatR;
+
+namespace Application.Handlers.Sync;
+
+public sealed class GetSyncDeltaQueryHandler(
+ IMessageRepository messageRepository,
+ IKeyExchangeRepository keyExchangeRepository,
+ IPublicKeyRepository publicKeyRepository) : IRequestHandler
+{
+ private const int DefaultLimit = 200;
+ private const int MaxLimit = 1000;
+
+ public async Task Handle(GetSyncDeltaQuery request, CancellationToken cancellationToken)
+ {
+ DateTime since = request.Since?.ToUniversalTime() ?? DateTime.UnixEpoch;
+ int limit = Math.Clamp(request.Limit ?? DefaultLimit, 1, MaxLimit);
+
+ IReadOnlyList messages = await messageRepository.GetSinceAsync(
+ request.CurrentUserFingerprint, since, limit, request.PeerFilter, cancellationToken);
+
+ IReadOnlyList keyExchanges = await keyExchangeRepository.GetSinceAsync(
+ request.CurrentUserFingerprint, since, limit, request.PeerFilter, cancellationToken);
+
+ if (messages.Count == 0 && keyExchanges.Count == 0)
+ return new SyncDeltaDto(DateTime.UtcNow, [], [], []);
+
+ HashSet fingerprints =
+ [
+ request.CurrentUserFingerprint,
+ .. messages.SelectMany(m => new[] { m.FromPublicKey, m.ToPublicKey }),
+ .. keyExchanges.SelectMany(k => new[] { k.FromPublicKey, k.ToPublicKey })
+ ];
+
+ IReadOnlyList profiles =
+ await publicKeyRepository.GetByFingerprintsAsync(fingerprints, cancellationToken);
+
+ return new SyncDeltaDto(DateTime.UtcNow, profiles, keyExchanges, messages);
+ }
+}
diff --git a/Application/Interfaces/ICurrentPublicKey.cs b/Application/Interfaces/ICurrentPublicKey.cs
deleted file mode 100644
index 2a77812..0000000
--- a/Application/Interfaces/ICurrentPublicKey.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Application;
-
-public interface ICurrentPublicKey
-{
- string GetFingerprintSha512();
-}
diff --git a/Application/Interfaces/IKeyExchangeRepository.cs b/Application/Interfaces/IKeyExchangeRepository.cs
new file mode 100644
index 0000000..227cc5b
--- /dev/null
+++ b/Application/Interfaces/IKeyExchangeRepository.cs
@@ -0,0 +1,25 @@
+using Application.DTOs;
+using Domain;
+
+namespace Application.Interfaces;
+
+public interface IKeyExchangeRepository
+{
+ Task AddOrUpdateAsync(KeyExchange keyExchange, CancellationToken ct = default);
+
+ Task ExistsAsync(string fromFingerprint, string toFingerprint, CancellationToken ct = default);
+
+ Task> GetConversationAsync(
+ string userFingerprint,
+ string peerFingerprint,
+ DateTime? fromDate,
+ DateTime? toDate,
+ CancellationToken ct = default);
+
+ Task> GetSinceAsync(
+ string userFingerprint,
+ DateTime since,
+ int limit,
+ string? peerFilter,
+ CancellationToken ct = default);
+}
diff --git a/Application/Interfaces/ILoginChallengeService.cs b/Application/Interfaces/ILoginChallengeService.cs
index 0c58443..1b45bd3 100644
--- a/Application/Interfaces/ILoginChallengeService.cs
+++ b/Application/Interfaces/ILoginChallengeService.cs
@@ -1,6 +1,8 @@
-namespace Application;
+namespace Application.Interfaces;
public interface ILoginChallengeService
{
- byte[] GetChallenge(string fingerprintSha512);
+ Task CreateChallengeAsync(string fingerprintSha512, CancellationToken ct = default);
+
+ Task ConsumeValidChallengeAsync(string fingerprintSha512, byte[] challenge, CancellationToken ct = default);
}
diff --git a/Application/Interfaces/ILoginService.cs b/Application/Interfaces/ILoginService.cs
index 8761abc..d39b432 100644
--- a/Application/Interfaces/ILoginService.cs
+++ b/Application/Interfaces/ILoginService.cs
@@ -1,6 +1,10 @@
-namespace Application;
+namespace Application.Interfaces;
public interface ILoginService
{
- string Login(string fingerprintSha512, byte[] challenge, byte[] signature);
+ Task ValidateAndConsumeAsync(
+ string fingerprintSha512,
+ byte[] challenge,
+ byte[] signature,
+ CancellationToken ct = default);
}
diff --git a/Application/Interfaces/IMessageRepository.cs b/Application/Interfaces/IMessageRepository.cs
new file mode 100644
index 0000000..638482a
--- /dev/null
+++ b/Application/Interfaces/IMessageRepository.cs
@@ -0,0 +1,23 @@
+using Application.DTOs;
+using Domain;
+
+namespace Application.Interfaces;
+
+public interface IMessageRepository
+{
+ Task AddAsync(Message message, CancellationToken ct = default);
+
+ Task> GetConversationAsync(
+ string userFingerprint,
+ string peerFingerprint,
+ DateTime? fromDate,
+ DateTime? toDate,
+ CancellationToken ct = default);
+
+ Task> GetSinceAsync(
+ string userFingerprint,
+ DateTime since,
+ int limit,
+ string? peerFilter,
+ CancellationToken ct = default);
+}
diff --git a/Application/Interfaces/IPublicKeyRepository.cs b/Application/Interfaces/IPublicKeyRepository.cs
index 285a231..c0e10a6 100644
--- a/Application/Interfaces/IPublicKeyRepository.cs
+++ b/Application/Interfaces/IPublicKeyRepository.cs
@@ -1,8 +1,13 @@
+using Application.DTOs;
using Domain;
-namespace Application;
+namespace Application.Interfaces;
public interface IPublicKeyRepository
{
- PublicKey GetRequired(string fingerprintSha512);
+ Task FindAsync(string fingerprintSha512, CancellationToken ct = default);
+ Task ExistsAsync(string fingerprintSha512, CancellationToken ct = default);
+ Task AddAsync(PublicKey publicKey, CancellationToken ct = default);
+ Task> SearchAsync(string userNamePattern, uint? userTag, int limit, CancellationToken ct = default);
+ Task> GetByFingerprintsAsync(IEnumerable fingerprints, CancellationToken ct = default);
}
diff --git a/Application/Interfaces/IPublicKeySecurityService.cs b/Application/Interfaces/IPublicKeySecurityService.cs
index 3c072ca..d8716eb 100644
--- a/Application/Interfaces/IPublicKeySecurityService.cs
+++ b/Application/Interfaces/IPublicKeySecurityService.cs
@@ -1,8 +1,7 @@
-namespace Application;
+namespace Application.Interfaces;
public interface IPublicKeySecurityService
{
void EnsureValidRsaPublicKey(byte[] der);
-
string ComputeFingerprintSha512(byte[] der);
}
diff --git a/Application/Interfaces/ISyncNotifier.cs b/Application/Interfaces/ISyncNotifier.cs
new file mode 100644
index 0000000..212e478
--- /dev/null
+++ b/Application/Interfaces/ISyncNotifier.cs
@@ -0,0 +1,7 @@
+namespace Application.Interfaces;
+
+public interface ISyncNotifier
+{
+ void NotifyMessage(string fromPublicKey, string toPublicKey);
+ void NotifyKeyExchange(string fromPublicKey, string toPublicKey);
+}
diff --git a/Application/Queries/GetKeyExchangesQuery.cs b/Application/Queries/GetKeyExchangesQuery.cs
new file mode 100644
index 0000000..f7f5593
--- /dev/null
+++ b/Application/Queries/GetKeyExchangesQuery.cs
@@ -0,0 +1,10 @@
+using Application.DTOs;
+using MediatR;
+
+namespace Application.Queries;
+
+public sealed record GetKeyExchangesQuery(
+ string CurrentUserFingerprint,
+ string PeerPublicKey,
+ DateTime? FromDate,
+ DateTime? ToDate) : IRequest>;
diff --git a/Application/Queries/GetMessagesQuery.cs b/Application/Queries/GetMessagesQuery.cs
new file mode 100644
index 0000000..6606c51
--- /dev/null
+++ b/Application/Queries/GetMessagesQuery.cs
@@ -0,0 +1,10 @@
+using Application.DTOs;
+using MediatR;
+
+namespace Application.Queries;
+
+public sealed record GetMessagesQuery(
+ string CurrentUserFingerprint,
+ string PeerPublicKey,
+ DateTime? FromDate,
+ DateTime? ToDate) : IRequest>;
diff --git a/Application/Queries/GetSyncDeltaQuery.cs b/Application/Queries/GetSyncDeltaQuery.cs
new file mode 100644
index 0000000..3f86b0b
--- /dev/null
+++ b/Application/Queries/GetSyncDeltaQuery.cs
@@ -0,0 +1,10 @@
+using Application.DTOs;
+using MediatR;
+
+namespace Application.Queries;
+
+public sealed record GetSyncDeltaQuery(
+ string CurrentUserFingerprint,
+ DateTime? Since,
+ int? Limit,
+ string? PeerFilter) : IRequest;
diff --git a/Application/Queries/SearchPublicKeysQuery.cs b/Application/Queries/SearchPublicKeysQuery.cs
new file mode 100644
index 0000000..0b6def1
--- /dev/null
+++ b/Application/Queries/SearchPublicKeysQuery.cs
@@ -0,0 +1,7 @@
+using Application.DTOs;
+using MediatR;
+
+namespace Application.Queries;
+
+public sealed record SearchPublicKeysQuery(string UserName, uint? UserTag, int? Limit)
+ : IRequest>;
diff --git a/Application/Validators/Auth/GetLoginChallengeCommandValidator.cs b/Application/Validators/Auth/GetLoginChallengeCommandValidator.cs
new file mode 100644
index 0000000..252e364
--- /dev/null
+++ b/Application/Validators/Auth/GetLoginChallengeCommandValidator.cs
@@ -0,0 +1,15 @@
+using Application.Commands;
+using FluentValidation;
+
+namespace Application.Validators.Auth;
+
+public sealed class GetLoginChallengeCommandValidator : AbstractValidator
+{
+ public GetLoginChallengeCommandValidator()
+ {
+ RuleFor(x => x.FingerprintSha512)
+ .NotEmpty().WithMessage("FingerprintSha512 is required.")
+ .Length(128).WithMessage("FingerprintSha512 must be exactly 128 characters.")
+ .Matches(@"^[0-9a-fA-F]{128}$").WithMessage("FingerprintSha512 must be a hexadecimal string.");
+ }
+}
diff --git a/Application/Validators/Auth/LoginCommandValidator.cs b/Application/Validators/Auth/LoginCommandValidator.cs
new file mode 100644
index 0000000..2d57ef8
--- /dev/null
+++ b/Application/Validators/Auth/LoginCommandValidator.cs
@@ -0,0 +1,18 @@
+using Application.Commands;
+using FluentValidation;
+
+namespace Application.Validators.Auth;
+
+public sealed class LoginCommandValidator : AbstractValidator
+{
+ public LoginCommandValidator()
+ {
+ RuleFor(x => x.FingerprintSha512)
+ .NotEmpty()
+ .Length(128)
+ .Matches(@"^[0-9a-fA-F]{128}$").WithMessage("FingerprintSha512 must be a hexadecimal string.");
+
+ RuleFor(x => x.ChallengeBase64).NotEmpty().WithMessage("ChallengeBase64 is required.");
+ RuleFor(x => x.SignatureBase64).NotEmpty().WithMessage("SignatureBase64 is required.");
+ }
+}
diff --git a/Application/Validators/Auth/RegisterCommandValidator.cs b/Application/Validators/Auth/RegisterCommandValidator.cs
new file mode 100644
index 0000000..e488463
--- /dev/null
+++ b/Application/Validators/Auth/RegisterCommandValidator.cs
@@ -0,0 +1,23 @@
+using Application.Commands;
+using FluentValidation;
+
+namespace Application.Validators.Auth;
+
+public sealed class RegisterCommandValidator : AbstractValidator
+{
+ public RegisterCommandValidator()
+ {
+ RuleFor(x => x.DerBase64)
+ .NotEmpty().WithMessage("DerBase64 is required.");
+
+ RuleFor(x => x.UserName)
+ .NotEmpty().WithMessage("UserName is required.")
+ .MinimumLength(3).WithMessage("UserName must be at least 3 characters long.")
+ .MaximumLength(32).WithMessage("UserName cannot exceed 32 characters.")
+ .Matches(@"^[a-zA-Z0-9_-]+$").WithMessage("UserName can only contain letters, digits, underscores, and hyphens.");
+
+ RuleFor(x => x.UserTag)
+ .GreaterThan(0u).WithMessage("UserTag must be greater than 0.")
+ .LessThanOrEqualTo(99999u).WithMessage("UserTag must be less than or equal to 99999.");
+ }
+}
diff --git a/Application/Validators/KeyExchanges/SendKeyExchangeCommandValidator.cs b/Application/Validators/KeyExchanges/SendKeyExchangeCommandValidator.cs
new file mode 100644
index 0000000..0626433
--- /dev/null
+++ b/Application/Validators/KeyExchanges/SendKeyExchangeCommandValidator.cs
@@ -0,0 +1,17 @@
+using Application.Commands;
+using FluentValidation;
+
+namespace Application.Validators.KeyExchanges;
+
+public sealed class SendKeyExchangeCommandValidator : AbstractValidator
+{
+ public SendKeyExchangeCommandValidator()
+ {
+ RuleFor(x => x.ToPublicKey)
+ .NotEmpty()
+ .Length(128)
+ .Matches(@"^[0-9a-fA-F]{128}$").WithMessage("ToPublicKey must be a 128-character hexadecimal string.");
+
+ RuleFor(x => x.EncryptedPrivateKeyBase64).NotEmpty().WithMessage("EncryptedPrivateKeyBase64 is required.");
+ }
+}
diff --git a/Application/Validators/Messages/SendMessageCommandValidator.cs b/Application/Validators/Messages/SendMessageCommandValidator.cs
new file mode 100644
index 0000000..35ffb50
--- /dev/null
+++ b/Application/Validators/Messages/SendMessageCommandValidator.cs
@@ -0,0 +1,22 @@
+using Application.Commands;
+using FluentValidation;
+
+namespace Application.Validators.Messages;
+
+public sealed class SendMessageCommandValidator : AbstractValidator
+{
+ public SendMessageCommandValidator()
+ {
+ RuleFor(x => x.ToPublicKey)
+ .NotEmpty()
+ .Length(128)
+ .Matches(@"^[0-9a-fA-F]{128}$").WithMessage("ToPublicKey must be a 128-character hexadecimal string.");
+
+ RuleFor(x => x.EncryptedContentBase64).NotEmpty().WithMessage("EncryptedContentBase64 is required.");
+
+ RuleFor(x => x.MessageHash)
+ .NotEmpty()
+ .Length(128)
+ .Matches(@"^[0-9a-fA-F]{128}$").WithMessage("MessageHash must be a 128-character hexadecimal string.");
+ }
+}
diff --git a/Application/Validators/PublicKeys/SearchPublicKeysQueryValidator.cs b/Application/Validators/PublicKeys/SearchPublicKeysQueryValidator.cs
new file mode 100644
index 0000000..f901070
--- /dev/null
+++ b/Application/Validators/PublicKeys/SearchPublicKeysQueryValidator.cs
@@ -0,0 +1,18 @@
+using Application.Queries;
+using FluentValidation;
+
+namespace Application.Validators.PublicKeys;
+
+public sealed class SearchPublicKeysQueryValidator : AbstractValidator
+{
+ public SearchPublicKeysQueryValidator()
+ {
+ RuleFor(x => x.UserName)
+ .NotEmpty().WithMessage("UserName is required.")
+ .MinimumLength(2).WithMessage("UserName must be at least 2 characters.");
+
+ RuleFor(x => x.Limit)
+ .InclusiveBetween(1, 100).When(x => x.Limit.HasValue)
+ .WithMessage("Limit must be between 1 and 100.");
+ }
+}
diff --git a/Domain/BaseEntity.cs b/Domain/BaseEntity.cs
index a5c4450..cb1884a 100644
--- a/Domain/BaseEntity.cs
+++ b/Domain/BaseEntity.cs
@@ -2,13 +2,5 @@ namespace Domain;
public abstract class BaseEntity
{
- public DateTime CreatedAt
- {
- get;
- }
-
- public DateTime UpdatedAt
- {
- get;
- }
+ public DateTime CreatedAt { get; protected set; }
}
diff --git a/Domain/Fingerprint.cs b/Domain/Fingerprint.cs
new file mode 100644
index 0000000..45f7047
--- /dev/null
+++ b/Domain/Fingerprint.cs
@@ -0,0 +1,17 @@
+using System.Text.RegularExpressions;
+
+namespace Domain;
+
+internal static partial class Fingerprint
+{
+ [GeneratedRegex(@"^[0-9a-fA-F]{128}$", RegexOptions.Compiled)]
+ private static partial Regex HexPattern();
+
+ public static void Validate(string value, string paramName)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(value, paramName);
+
+ if (value.Length != 128 || !HexPattern().IsMatch(value))
+ throw new ArgumentException("Must be a 128-character hexadecimal string (SHA-512).", paramName);
+ }
+}
diff --git a/Domain/KeyExchange.cs b/Domain/KeyExchange.cs
index b86cfce..2702744 100644
--- a/Domain/KeyExchange.cs
+++ b/Domain/KeyExchange.cs
@@ -2,54 +2,27 @@ namespace Domain;
public sealed class KeyExchange : BaseEntity
{
- #region Properties
+ public string FromPublicKey { get; private set; } = string.Empty;
+ public string ToPublicKey { get; private set; } = string.Empty;
+ public byte[] EncryptedPrivateKey { get; private set; } = [];
- public string FromPublicKey
- {
- get; private set;
- }
+ private KeyExchange() { }
- public string ToPublicKey
+ public KeyExchange(string fromPublicKey, string toPublicKey, byte[] encryptedPrivateKey, DateTime? createdAt = null)
{
- get; private set;
- }
+ Fingerprint.Validate(fromPublicKey, nameof(fromPublicKey));
+ Fingerprint.Validate(toPublicKey, nameof(toPublicKey));
- public byte[] EncryptedPrivateKey
- {
- get; private set;
- }
-
- #endregion
-
- #region Constructors
-
- private KeyExchange()
- {
- }
-
- public KeyExchange(string fromPublicKey, string toPublicKey, byte[] encryptedPrivateKey)
- {
- ArgumentException.ThrowIfNullOrEmpty(fromPublicKey);
- ArgumentException.ThrowIfNullOrEmpty(toPublicKey);
-
- if (fromPublicKey.Length != 128)
- throw new ArgumentException("FromPublicKey must be exactly 128 characters long (hexadecimal SHA-512 hash format).", nameof(fromPublicKey));
-
- if (toPublicKey.Length != 128)
- throw new ArgumentException("ToPublicKey must be exactly 128 characters long (hexadecimal SHA-512 hash format).", nameof(toPublicKey));
-
- if (fromPublicKey == toPublicKey)
+ if (fromPublicKey.Equals(toPublicKey, StringComparison.Ordinal))
throw new ArgumentException("FromPublicKey and ToPublicKey cannot be the same.", nameof(toPublicKey));
ArgumentNullException.ThrowIfNull(encryptedPrivateKey);
-
if (encryptedPrivateKey.Length == 0)
throw new ArgumentException("EncryptedPrivateKey cannot be empty.", nameof(encryptedPrivateKey));
FromPublicKey = fromPublicKey;
ToPublicKey = toPublicKey;
EncryptedPrivateKey = encryptedPrivateKey;
+ CreatedAt = createdAt?.ToUniversalTime() ?? DateTime.UtcNow;
}
-
- #endregion
}
diff --git a/Domain/Message.cs b/Domain/Message.cs
index 548fc6b..59efd73 100644
--- a/Domain/Message.cs
+++ b/Domain/Message.cs
@@ -2,72 +2,31 @@ namespace Domain;
public sealed class Message : BaseEntity
{
- #region Properties
+ public string FromPublicKey { get; private set; } = string.Empty;
+ public string ToPublicKey { get; private set; } = string.Empty;
+ public byte[] EncryptedContent { get; private set; } = [];
+ public string MessageHash { get; private set; } = string.Empty;
- public string FromPublicKey
- {
- get; private set;
- }
-
- public string ToPublicKey
- {
- get; private set;
- }
-
- public byte[] EncryptedContent
- {
- get; private set;
- }
-
- public string MessageHash
- {
- get; private set;
- }
-
- #endregion
+ private Message() { }
- #region Constructors
-
- private Message()
- {
- }
-
- public Message(string fromPublicKey, string toPublicKey, byte[] encryptedContent, string messageHash)
+ public Message(string fromPublicKey, string toPublicKey, byte[] encryptedContent, string messageHash, DateTime? createdAt = null)
{
- ArgumentException.ThrowIfNullOrEmpty(fromPublicKey);
- ArgumentException.ThrowIfNullOrEmpty(toPublicKey);
- ArgumentNullException.ThrowIfNull(encryptedContent);
- ArgumentException.ThrowIfNullOrEmpty(messageHash);
-
- if (fromPublicKey.Length != 128)
- throw new ArgumentException("FromPublicKey must be exactly 128 characters long (hexadecimal SHA-512 hash format).", nameof(fromPublicKey));
+ Fingerprint.Validate(fromPublicKey, nameof(fromPublicKey));
+ Fingerprint.Validate(toPublicKey, nameof(toPublicKey));
- if (toPublicKey.Length != 128)
- throw new ArgumentException("ToPublicKey must be exactly 128 characters long (hexadecimal SHA-512 hash format).", nameof(toPublicKey));
-
- if (!System.Text.RegularExpressions.Regex.IsMatch(fromPublicKey, @"^[0-9a-fA-F]{128}$"))
- throw new ArgumentException("FromPublicKey must contain only hexadecimal characters (0-9, a-f, A-F).", nameof(fromPublicKey));
-
- if (!System.Text.RegularExpressions.Regex.IsMatch(toPublicKey, @"^[0-9a-fA-F]{128}$"))
- throw new ArgumentException("ToPublicKey must contain only hexadecimal characters (0-9, a-f, A-F).", nameof(toPublicKey));
-
- if (fromPublicKey == toPublicKey)
+ if (fromPublicKey.Equals(toPublicKey, StringComparison.Ordinal))
throw new ArgumentException("FromPublicKey and ToPublicKey cannot be the same.", nameof(toPublicKey));
+ ArgumentNullException.ThrowIfNull(encryptedContent);
if (encryptedContent.Length == 0)
throw new ArgumentException("EncryptedContent cannot be empty.", nameof(encryptedContent));
- if (messageHash.Length != 128)
- throw new ArgumentException("MessageHash must be exactly 128 characters long (hexadecimal SHA-512 hash format).", nameof(messageHash));
-
- if (!System.Text.RegularExpressions.Regex.IsMatch(messageHash, @"^[0-9a-fA-F]{128}$"))
- throw new ArgumentException("MessageHash must contain only hexadecimal characters (0-9, a-f, A-F).", nameof(messageHash));
+ Fingerprint.Validate(messageHash, nameof(messageHash));
FromPublicKey = fromPublicKey;
ToPublicKey = toPublicKey;
EncryptedContent = encryptedContent;
MessageHash = messageHash;
+ CreatedAt = createdAt?.ToUniversalTime() ?? DateTime.UtcNow;
}
-
- #endregion
}
diff --git a/Domain/PublicKey.cs b/Domain/PublicKey.cs
index 4b46663..af525e8 100644
--- a/Domain/PublicKey.cs
+++ b/Domain/PublicKey.cs
@@ -1,75 +1,31 @@
-namespace Domain;
+using System.Text.RegularExpressions;
+
+namespace Domain;
public sealed class PublicKey : BaseEntity
{
- #region Properties
-
- public string FingerprintSha512
- {
- get; private set;
- }
-
- public byte[] Der
- {
- get; private set;
- }
-
- public string UserName
- {
- get; private set;
- }
-
- public uint UserTag
- {
- get; private set;
- }
+ public string FingerprintSha512 { get; private set; } = string.Empty;
+ public byte[] Der { get; private set; } = [];
+ public string UserName { get; private set; } = string.Empty;
+ public uint UserTag { get; private set; }
- private List _myKeyExchanges = [];
+ private static readonly Regex UserNamePattern =
+ new(@"^[a-zA-Z0-9_-]+$", RegexOptions.Compiled);
- public IReadOnlyList MyKeyExchanges => _myKeyExchanges.AsReadOnly();
+ private PublicKey() { }
- private List _yourKeyExchanges = [];
-
- public IReadOnlyList YourKeyExchanges => _yourKeyExchanges.AsReadOnly();
-
- private List _myMessages = [];
-
- public IReadOnlyList MyMessages => _myMessages.AsReadOnly();
-
- private List _yourMessages = [];
-
- public IReadOnlyList YourMessages => _yourMessages.AsReadOnly();
-
- #endregion
-
- #region Constructors
-
- private PublicKey()
- {
- }
-
- public PublicKey(byte[] der,
- string fingerprintSha512,
- string userName,
- uint userTag)
+ public PublicKey(byte[] der, string fingerprintSha512, string userName, uint userTag)
{
ArgumentNullException.ThrowIfNull(der);
- ArgumentException.ThrowIfNullOrEmpty(fingerprintSha512);
- ArgumentException.ThrowIfNullOrEmpty(userName);
-
if (der.Length == 0)
throw new ArgumentException("DER public key cannot be empty.", nameof(der));
- if (fingerprintSha512.Length != 128)
- throw new ArgumentException("SHA-512 fingerprint must be exactly 128 characters long (hexadecimal format).", nameof(fingerprintSha512));
-
- if (!System.Text.RegularExpressions.Regex.IsMatch(fingerprintSha512, @"^[0-9a-fA-F]{128}$"))
- throw new ArgumentException("SHA-512 fingerprint must contain only hexadecimal characters (0-9, a-f, A-F).", nameof(fingerprintSha512));
+ Fingerprint.Validate(fingerprintSha512, nameof(fingerprintSha512));
if (userName.Length < 3 || userName.Length > 32)
throw new ArgumentException("UserName must be between 3 and 32 characters long.", nameof(userName));
- if (!System.Text.RegularExpressions.Regex.IsMatch(userName, @"^[a-zA-Z0-9_-]+$"))
+ if (!UserNamePattern.IsMatch(userName))
throw new ArgumentException("UserName can only contain alphanumeric characters, underscores, and hyphens.", nameof(userName));
if (userTag == 0)
@@ -82,77 +38,6 @@ public PublicKey(byte[] der,
FingerprintSha512 = fingerprintSha512;
UserName = userName;
UserTag = userTag;
+ CreatedAt = DateTime.UtcNow;
}
-
- #endregion
-
- #region Methods
-
- public void AddKeyExchange(string toPublicKey, byte[] encryptedPrivateKey) =>
- _myKeyExchanges.Add(new(FingerprintSha512, toPublicKey, encryptedPrivateKey));
-
- public Message SendMessage(string toPublicKey, byte[] encryptedContent, string messageHash)
- {
- ArgumentException.ThrowIfNullOrEmpty(toPublicKey);
-
- bool hasKeyExchangeForRecipient = _myKeyExchanges.Any(x => x.ToPublicKey == toPublicKey);
- if (!hasKeyExchangeForRecipient)
- throw new InvalidOperationException("Cannot send message without a key exchange from owner to recipient.");
-
- Message message = new(FingerprintSha512, toPublicKey, encryptedContent, messageHash);
- _myMessages.Add(message);
- return message;
- }
-
- public IReadOnlyList GetMessages(
- string toPublicKey,
- DateTime? fromDate = null,
- DateTime? toDate = null)
- {
- ArgumentException.ThrowIfNullOrEmpty(toPublicKey);
-
- if (fromDate.HasValue && toDate.HasValue && fromDate.Value > toDate.Value)
- throw new ArgumentException("fromDate cannot be greater than toDate.", nameof(fromDate));
-
- IEnumerable messages = _myMessages
- .Concat(_yourMessages)
- .Where(message => message.FromPublicKey == toPublicKey || message.ToPublicKey == toPublicKey);
-
- if (fromDate.HasValue)
- messages = messages.Where(message => message.CreatedAt >= fromDate.Value);
-
- if (toDate.HasValue)
- messages = messages.Where(message => message.CreatedAt <= toDate.Value);
-
- return messages
- .OrderBy(message => message.CreatedAt)
- .ToList();
- }
-
- public IReadOnlyList GetKeyExchanges(
- string toPublicKey,
- DateTime? fromDate = null,
- DateTime? toDate = null)
- {
- ArgumentException.ThrowIfNullOrEmpty(toPublicKey);
-
- if (fromDate.HasValue && toDate.HasValue && fromDate.Value > toDate.Value)
- throw new ArgumentException("fromDate cannot be greater than toDate.", nameof(fromDate));
-
- IEnumerable keyExchanges = _myKeyExchanges
- .Concat(_yourKeyExchanges)
- .Where(keyExchange => keyExchange.FromPublicKey == toPublicKey || keyExchange.ToPublicKey == toPublicKey);
-
- if (fromDate.HasValue)
- keyExchanges = keyExchanges.Where(keyExchange => keyExchange.CreatedAt >= fromDate.Value);
-
- if (toDate.HasValue)
- keyExchanges = keyExchanges.Where(keyExchange => keyExchange.CreatedAt <= toDate.Value);
-
- return keyExchanges
- .OrderBy(keyExchange => keyExchange.CreatedAt)
- .ToList();
- }
-
- #endregion
}
diff --git a/Infrastructure/DependencyInjection.cs b/Infrastructure/DependencyInjection.cs
index c96b14d..cc29a62 100644
--- a/Infrastructure/DependencyInjection.cs
+++ b/Infrastructure/DependencyInjection.cs
@@ -1,4 +1,4 @@
-using Application;
+using Application.Interfaces;
using Infrastructure.Persistence;
using Infrastructure.Services;
using Microsoft.EntityFrameworkCore;
@@ -8,19 +8,18 @@ namespace Infrastructure;
public static class DependencyInjection
{
- public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
+ public static IServiceCollection RegisterInfrastructureServices(this IServiceCollection services, string connectionString)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
services.AddDbContext(options => options.UseNpgsql(connectionString));
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
- services.AddScoped();
-
- services.AddSingleton();
- services.AddSingleton(sp => sp.GetRequiredService());
return services;
}
diff --git a/Infrastructure/Services/CurrentPublicKeyAccessor.cs b/Infrastructure/Services/CurrentPublicKeyAccessor.cs
deleted file mode 100644
index 0890b73..0000000
--- a/Infrastructure/Services/CurrentPublicKeyAccessor.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Application;
-
-namespace Infrastructure.Services;
-
-public sealed class CurrentPublicKeyAccessor : ICurrentPublicKey
-{
- private static readonly AsyncLocal _currentFingerprint = new();
-
- public void SetFingerprintSha512(string fingerprintSha512)
- {
- ArgumentException.ThrowIfNullOrEmpty(fingerprintSha512);
- _currentFingerprint.Value = fingerprintSha512;
- }
-
- public string GetFingerprintSha512()
- {
- if (string.IsNullOrWhiteSpace(_currentFingerprint.Value))
- throw new InvalidOperationException("Current public key fingerprint is not set in request context.");
-
- return _currentFingerprint.Value;
- }
-}
diff --git a/Infrastructure/Services/KeyExchangeRepository.cs b/Infrastructure/Services/KeyExchangeRepository.cs
new file mode 100644
index 0000000..687a7fd
--- /dev/null
+++ b/Infrastructure/Services/KeyExchangeRepository.cs
@@ -0,0 +1,91 @@
+using Application.DTOs;
+using Application.Interfaces;
+using Domain;
+using Infrastructure.Persistence;
+using Infrastructure.Persistence.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace Infrastructure.Services;
+
+public sealed class KeyExchangeRepository(MessagerDbContext dbContext) : IKeyExchangeRepository
+{
+ public async Task AddOrUpdateAsync(KeyExchange keyExchange, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(keyExchange);
+
+ KeyExchangeRecord? existing = await dbContext.KeyExchanges
+ .SingleOrDefaultAsync(
+ x => x.FromPublicKey == keyExchange.FromPublicKey && x.ToPublicKey == keyExchange.ToPublicKey,
+ ct);
+
+ if (existing is null)
+ {
+ dbContext.KeyExchanges.Add(new KeyExchangeRecord
+ {
+ FromPublicKey = keyExchange.FromPublicKey,
+ ToPublicKey = keyExchange.ToPublicKey,
+ EncryptedPrivateKey = keyExchange.EncryptedPrivateKey,
+ CreatedAt = keyExchange.CreatedAt
+ });
+ }
+ else
+ {
+ existing.EncryptedPrivateKey = keyExchange.EncryptedPrivateKey;
+ existing.CreatedAt = DateTime.UtcNow;
+ }
+
+ await dbContext.SaveChangesAsync(ct);
+ }
+
+ public Task ExistsAsync(string fromFingerprint, string toFingerprint, CancellationToken ct = default)
+ {
+ return dbContext.KeyExchanges
+ .AnyAsync(x => x.FromPublicKey == fromFingerprint && x.ToPublicKey == toFingerprint, ct);
+ }
+
+ public async Task> GetConversationAsync(
+ string userFingerprint,
+ string peerFingerprint,
+ DateTime? fromDate,
+ DateTime? toDate,
+ CancellationToken ct = default)
+ {
+ IQueryable query = dbContext.KeyExchanges
+ .Where(x =>
+ (x.FromPublicKey == userFingerprint && x.ToPublicKey == peerFingerprint) ||
+ (x.FromPublicKey == peerFingerprint && x.ToPublicKey == userFingerprint));
+
+ if (fromDate.HasValue)
+ query = query.Where(x => x.CreatedAt >= fromDate.Value.ToUniversalTime());
+
+ if (toDate.HasValue)
+ query = query.Where(x => x.CreatedAt <= toDate.Value.ToUniversalTime());
+
+ return await query
+ .OrderBy(x => x.CreatedAt)
+ .Select(x => new KeyExchangeDto(x.FromPublicKey, x.ToPublicKey, x.EncryptedPrivateKey, x.CreatedAt))
+ .ToListAsync(ct);
+ }
+
+ public async Task> GetSinceAsync(
+ string userFingerprint,
+ DateTime since,
+ int limit,
+ string? peerFilter,
+ CancellationToken ct = default)
+ {
+ IQueryable query = dbContext.KeyExchanges
+ .Where(x =>
+ (x.FromPublicKey == userFingerprint || x.ToPublicKey == userFingerprint) &&
+ x.CreatedAt > since);
+
+ if (!string.IsNullOrWhiteSpace(peerFilter))
+ query = query.Where(x => x.FromPublicKey == peerFilter || x.ToPublicKey == peerFilter);
+
+ return await query
+ .OrderBy(x => x.CreatedAt)
+ .Take(limit)
+ .Select(x => new KeyExchangeDto(x.FromPublicKey, x.ToPublicKey, x.EncryptedPrivateKey, x.CreatedAt))
+ .ToListAsync(ct);
+ }
+}
diff --git a/Infrastructure/Services/LoginChallengeService.cs b/Infrastructure/Services/LoginChallengeService.cs
index 60a6b2a..8fa2c8e 100644
--- a/Infrastructure/Services/LoginChallengeService.cs
+++ b/Infrastructure/Services/LoginChallengeService.cs
@@ -1,4 +1,5 @@
-using Application;
+using Application.Exceptions;
+using Application.Interfaces;
using Infrastructure.Persistence;
using Infrastructure.Persistence.Models;
using Microsoft.EntityFrameworkCore;
@@ -8,37 +9,57 @@ namespace Infrastructure.Services;
public sealed class LoginChallengeService(MessagerDbContext dbContext) : ILoginChallengeService
{
- private readonly MessagerDbContext _dbContext = dbContext;
+ private static readonly TimeSpan ChallengeExpiry = TimeSpan.FromMinutes(5);
- public byte[] GetChallenge(string fingerprintSha512)
+ public async Task CreateChallengeAsync(string fingerprintSha512, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(fingerprintSha512);
- bool exists = _dbContext.PublicKeys.Any(x => x.FingerprintSha512 == fingerprintSha512);
- if (!exists)
- throw new InvalidOperationException("Public key does not exist.");
-
DateTime now = DateTime.UtcNow;
- List expiredChallenges = _dbContext.LoginChallenges
+ List stale = await dbContext.LoginChallenges
.Where(x => x.FingerprintSha512 == fingerprintSha512 && (x.ConsumedAt != null || x.ExpiresAt <= now))
- .ToList();
+ .ToListAsync(ct);
- if (expiredChallenges.Count > 0)
- _dbContext.LoginChallenges.RemoveRange(expiredChallenges);
+ if (stale.Count > 0)
+ dbContext.LoginChallenges.RemoveRange(stale);
byte[] challenge = RandomNumberGenerator.GetBytes(64);
- _dbContext.LoginChallenges.Add(new LoginChallengeRecord
+ dbContext.LoginChallenges.Add(new LoginChallengeRecord
{
Id = Guid.NewGuid(),
FingerprintSha512 = fingerprintSha512,
Challenge = challenge,
CreatedAt = now,
- ExpiresAt = now.AddMinutes(5)
+ ExpiresAt = now.Add(ChallengeExpiry)
});
- _dbContext.SaveChanges();
+ await dbContext.SaveChangesAsync(ct);
return challenge;
}
+
+ public async Task ConsumeValidChallengeAsync(string fingerprintSha512, byte[] challenge, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(fingerprintSha512);
+ ArgumentNullException.ThrowIfNull(challenge);
+
+ DateTime now = DateTime.UtcNow;
+
+ LoginChallengeRecord? record = await dbContext.LoginChallenges
+ .SingleOrDefaultAsync(x =>
+ x.FingerprintSha512 == fingerprintSha512 &&
+ x.ConsumedAt == null &&
+ x.ExpiresAt > now &&
+ x.Challenge == challenge,
+ ct);
+
+ if (record is null)
+ throw new UnauthorizedException("Challenge is invalid or has expired.");
+
+ record.ConsumedAt = now;
+ await dbContext.SaveChangesAsync(ct);
+
+ return record.Challenge;
+ }
}
diff --git a/Infrastructure/Services/LoginService.cs b/Infrastructure/Services/LoginService.cs
index 91f6eaf..8d3976f 100644
--- a/Infrastructure/Services/LoginService.cs
+++ b/Infrastructure/Services/LoginService.cs
@@ -1,50 +1,48 @@
-using Application;
-using Infrastructure.Persistence;
-using Microsoft.EntityFrameworkCore;
+using Application.Exceptions;
+using Application.Interfaces;
+using Domain;
using System.Security.Cryptography;
namespace Infrastructure.Services;
-public sealed class LoginService(MessagerDbContext dbContext) : ILoginService
+public sealed class LoginService(
+ IPublicKeyRepository publicKeyRepository,
+ ILoginChallengeService loginChallengeService) : ILoginService
{
- private readonly MessagerDbContext _dbContext = dbContext;
-
- public string Login(string fingerprintSha512, byte[] challenge, byte[] signature)
+ public async Task ValidateAndConsumeAsync(
+ string fingerprintSha512,
+ byte[] challenge,
+ byte[] signature,
+ CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(fingerprintSha512);
ArgumentNullException.ThrowIfNull(challenge);
ArgumentNullException.ThrowIfNull(signature);
- DateTime now = DateTime.UtcNow;
+ PublicKey? publicKey = await publicKeyRepository.FindAsync(fingerprintSha512, ct);
+ if (publicKey is null)
+ throw new NotFoundException($"Public key '{fingerprintSha512[..8]}...' not found.");
- Domain.PublicKey publicKey = new PublicKeyRepository(_dbContext).GetRequired(fingerprintSha512);
+ await loginChallengeService.ConsumeValidChallengeAsync(fingerprintSha512, challenge, ct);
- Infrastructure.Persistence.Models.LoginChallengeRecord challengeRecord = _dbContext.LoginChallenges
- .SingleOrDefault(x =>
- x.FingerprintSha512 == fingerprintSha512 &&
- x.ConsumedAt == null &&
- x.ExpiresAt > now &&
- x.Challenge == challenge)
- ?? throw new InvalidOperationException("Challenge is invalid or expired.");
+ VerifySignature(publicKey.Der, challenge, signature);
+ }
+ private static void VerifySignature(byte[] der, byte[] challenge, byte[] signature)
+ {
using RSA rsa = RSA.Create();
try
{
- rsa.ImportSubjectPublicKeyInfo(publicKey.Der, out _);
+ rsa.ImportSubjectPublicKeyInfo(der, out _);
}
catch (CryptographicException)
{
- rsa.ImportRSAPublicKey(publicKey.Der, out _);
+ rsa.ImportRSAPublicKey(der, out _);
}
- bool verified = rsa.VerifyData(challenge, signature, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1);
- if (!verified)
- throw new InvalidOperationException("Invalid signature.");
-
- challengeRecord.ConsumedAt = now;
- _dbContext.SaveChanges();
-
- return Guid.NewGuid().ToString("N");
+ bool valid = rsa.VerifyData(challenge, signature, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1);
+ if (!valid)
+ throw new UnauthorizedException("Signature verification failed.");
}
}
diff --git a/Infrastructure/Services/MessageRepository.cs b/Infrastructure/Services/MessageRepository.cs
new file mode 100644
index 0000000..3e79408
--- /dev/null
+++ b/Infrastructure/Services/MessageRepository.cs
@@ -0,0 +1,73 @@
+using Application.DTOs;
+using Application.Interfaces;
+using Domain;
+using Infrastructure.Persistence;
+using Infrastructure.Persistence.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace Infrastructure.Services;
+
+public sealed class MessageRepository(MessagerDbContext dbContext) : IMessageRepository
+{
+ public async Task AddAsync(Message message, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ dbContext.Messages.Add(new MessageRecord
+ {
+ FromPublicKey = message.FromPublicKey,
+ ToPublicKey = message.ToPublicKey,
+ EncryptedContent = message.EncryptedContent,
+ MessageHash = message.MessageHash,
+ CreatedAt = message.CreatedAt
+ });
+
+ await dbContext.SaveChangesAsync(ct);
+ }
+
+ public async Task> GetConversationAsync(
+ string userFingerprint,
+ string peerFingerprint,
+ DateTime? fromDate,
+ DateTime? toDate,
+ CancellationToken ct = default)
+ {
+ IQueryable query = dbContext.Messages
+ .Where(x =>
+ (x.FromPublicKey == userFingerprint && x.ToPublicKey == peerFingerprint) ||
+ (x.FromPublicKey == peerFingerprint && x.ToPublicKey == userFingerprint));
+
+ if (fromDate.HasValue)
+ query = query.Where(x => x.CreatedAt >= fromDate.Value.ToUniversalTime());
+
+ if (toDate.HasValue)
+ query = query.Where(x => x.CreatedAt <= toDate.Value.ToUniversalTime());
+
+ return await query
+ .OrderBy(x => x.CreatedAt)
+ .Select(x => new MessageDto(x.FromPublicKey, x.ToPublicKey, x.EncryptedContent, x.MessageHash, x.CreatedAt))
+ .ToListAsync(ct);
+ }
+
+ public async Task> GetSinceAsync(
+ string userFingerprint,
+ DateTime since,
+ int limit,
+ string? peerFilter,
+ CancellationToken ct = default)
+ {
+ IQueryable query = dbContext.Messages
+ .Where(x =>
+ (x.FromPublicKey == userFingerprint || x.ToPublicKey == userFingerprint) &&
+ x.CreatedAt > since);
+
+ if (!string.IsNullOrWhiteSpace(peerFilter))
+ query = query.Where(x => x.FromPublicKey == peerFilter || x.ToPublicKey == peerFilter);
+
+ return await query
+ .OrderBy(x => x.CreatedAt)
+ .Take(limit)
+ .Select(x => new MessageDto(x.FromPublicKey, x.ToPublicKey, x.EncryptedContent, x.MessageHash, x.CreatedAt))
+ .ToListAsync(ct);
+ }
+}
diff --git a/Infrastructure/Services/PublicKeyRepository.cs b/Infrastructure/Services/PublicKeyRepository.cs
index b67ee74..bec4215 100644
--- a/Infrastructure/Services/PublicKeyRepository.cs
+++ b/Infrastructure/Services/PublicKeyRepository.cs
@@ -1,63 +1,73 @@
-using Application;
+using Application.DTOs;
+using Application.Interfaces;
using Domain;
using Infrastructure.Persistence;
+using Infrastructure.Persistence.Models;
using Microsoft.EntityFrameworkCore;
-using System.Reflection;
namespace Infrastructure.Services;
public sealed class PublicKeyRepository(MessagerDbContext dbContext) : IPublicKeyRepository
{
- private static readonly FieldInfo YourMessagesField =
- typeof(PublicKey).GetField("_yourMessages", BindingFlags.Instance | BindingFlags.NonPublic)
- ?? throw new MissingFieldException(typeof(PublicKey).FullName, "_yourMessages");
-
- private static readonly FieldInfo YourKeyExchangesField =
- typeof(PublicKey).GetField("_yourKeyExchanges", BindingFlags.Instance | BindingFlags.NonPublic)
- ?? throw new MissingFieldException(typeof(PublicKey).FullName, "_yourKeyExchanges");
-
- private readonly MessagerDbContext _dbContext = dbContext;
-
- public PublicKey GetRequired(string fingerprintSha512)
+ public async Task FindAsync(string fingerprintSha512, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(fingerprintSha512);
- Persistence.Models.PublicKeyRecord record = _dbContext.PublicKeys
- .SingleOrDefault(x => x.FingerprintSha512 == fingerprintSha512)
- ?? throw new InvalidOperationException("Public key not found.");
-
- PublicKey publicKey = new(record.Der, record.FingerprintSha512, record.UserName, record.UserTag);
+ PublicKeyRecord? record = await dbContext.PublicKeys
+ .AsNoTracking()
+ .SingleOrDefaultAsync(x => x.FingerprintSha512 == fingerprintSha512, ct);
- List sentKeyExchanges = _dbContext.KeyExchanges
- .Where(x => x.FromPublicKey == fingerprintSha512)
- .ToList();
-
- foreach (Persistence.Models.KeyExchangeRecord keyExchange in sentKeyExchanges)
- publicKey.AddKeyExchange(keyExchange.ToPublicKey, keyExchange.EncryptedPrivateKey);
-
- List receivedKeyExchanges = _dbContext.KeyExchanges
- .Where(x => x.ToPublicKey == fingerprintSha512)
- .ToList();
-
- List yourKeyExchanges = (List)YourKeyExchangesField.GetValue(publicKey)!;
- foreach (Persistence.Models.KeyExchangeRecord keyExchange in receivedKeyExchanges)
- yourKeyExchanges.Add(new KeyExchange(keyExchange.FromPublicKey, keyExchange.ToPublicKey, keyExchange.EncryptedPrivateKey));
+ return record is null ? null : new PublicKey(record.Der, record.FingerprintSha512, record.UserName, record.UserTag);
+ }
- List sentMessages = _dbContext.Messages
- .Where(x => x.FromPublicKey == fingerprintSha512)
- .ToList();
+ public Task ExistsAsync(string fingerprintSha512, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(fingerprintSha512);
+ return dbContext.PublicKeys.AnyAsync(x => x.FingerprintSha512 == fingerprintSha512, ct);
+ }
- foreach (Persistence.Models.MessageRecord message in sentMessages)
- publicKey.SendMessage(message.ToPublicKey, message.EncryptedContent, message.MessageHash);
+ public async Task AddAsync(PublicKey publicKey, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(publicKey);
+
+ DateTime now = DateTime.UtcNow;
+ dbContext.PublicKeys.Add(new PublicKeyRecord
+ {
+ FingerprintSha512 = publicKey.FingerprintSha512,
+ Der = publicKey.Der,
+ UserName = publicKey.UserName,
+ UserTag = publicKey.UserTag,
+ CreatedAt = now,
+ UpdatedAt = now
+ });
+
+ await dbContext.SaveChangesAsync(ct);
+ }
- List receivedMessages = _dbContext.Messages
- .Where(x => x.ToPublicKey == fingerprintSha512)
- .ToList();
+ public async Task> SearchAsync(string userNamePattern, uint? userTag, int limit, CancellationToken ct = default)
+ {
+ IQueryable query = dbContext.PublicKeys
+ .Where(x => EF.Functions.ILike(x.UserName, $"%{userNamePattern}%", "\\"));
+
+ if (userTag.HasValue)
+ query = query.Where(x => x.UserTag == userTag.Value);
+
+ return await query
+ .OrderBy(x => x.UserName)
+ .ThenBy(x => x.UserTag)
+ .ThenBy(x => x.FingerprintSha512)
+ .Take(limit)
+ .Select(x => new PublicKeyProfileDto(x.FingerprintSha512, x.UserName, x.UserTag, x.Der))
+ .ToListAsync(ct);
+ }
- List yourMessages = (List)YourMessagesField.GetValue(publicKey)!;
- foreach (Persistence.Models.MessageRecord message in receivedMessages)
- yourMessages.Add(new Message(message.FromPublicKey, message.ToPublicKey, message.EncryptedContent, message.MessageHash));
+ public async Task> GetByFingerprintsAsync(IEnumerable fingerprints, CancellationToken ct = default)
+ {
+ List fingerprintList = fingerprints.Distinct().ToList();
- return publicKey;
+ return await dbContext.PublicKeys
+ .Where(x => fingerprintList.Contains(x.FingerprintSha512))
+ .Select(x => new PublicKeyProfileDto(x.FingerprintSha512, x.UserName, x.UserTag, x.Der))
+ .ToListAsync(ct);
}
}
diff --git a/Infrastructure/Services/PublicKeySecurityService.cs b/Infrastructure/Services/PublicKeySecurityService.cs
index 09930f9..0a9f4b3 100644
--- a/Infrastructure/Services/PublicKeySecurityService.cs
+++ b/Infrastructure/Services/PublicKeySecurityService.cs
@@ -1,5 +1,5 @@
+using Application.Interfaces;
using System.Security.Cryptography;
-using Application;
namespace Infrastructure.Services;
@@ -14,25 +14,23 @@ public void EnsureValidRsaPublicKey(byte[] der)
try
{
rsa.ImportSubjectPublicKeyInfo(der, out _);
+ return;
}
- catch (CryptographicException)
+ catch (CryptographicException) { }
+
+ try
{
- try
- {
- rsa.ImportRSAPublicKey(der, out _);
- }
- catch (CryptographicException ex)
- {
- throw new ArgumentException("Invalid RSA public key format.", nameof(der), ex);
- }
+ rsa.ImportRSAPublicKey(der, out _);
+ }
+ catch (CryptographicException ex)
+ {
+ throw new ArgumentException("Invalid RSA public key format.", nameof(der), ex);
}
}
public string ComputeFingerprintSha512(byte[] der)
{
ArgumentNullException.ThrowIfNull(der);
-
- byte[] hash = SHA512.HashData(der);
- return Convert.ToHexString(hash).ToLowerInvariant();
+ return Convert.ToHexString(SHA512.HashData(der)).ToLowerInvariant();
}
}
diff --git a/Messager.slnx b/Messager.slnx
index 8930c64..d6433fc 100644
--- a/Messager.slnx
+++ b/Messager.slnx
@@ -3,4 +3,5 @@
+
diff --git a/Tests/Application/GetLoginChallengeCommandHandlerTests.cs b/Tests/Application/GetLoginChallengeCommandHandlerTests.cs
new file mode 100644
index 0000000..bae0678
--- /dev/null
+++ b/Tests/Application/GetLoginChallengeCommandHandlerTests.cs
@@ -0,0 +1,44 @@
+using Application.Commands;
+using Application.Exceptions;
+using Application.Handlers.Auth;
+using Application.Interfaces;
+using NSubstitute;
+
+namespace Tests.Application;
+
+public sealed class GetLoginChallengeCommandHandlerTests
+{
+ private readonly IPublicKeyRepository _repository = Substitute.For();
+ private readonly ILoginChallengeService _challengeService = Substitute.For();
+ private readonly GetLoginChallengeCommandHandler _handler;
+
+ private static readonly string Fingerprint = new('a', 128);
+
+ public GetLoginChallengeCommandHandlerTests()
+ {
+ _handler = new GetLoginChallengeCommandHandler(_repository, _challengeService);
+ }
+
+ [Fact]
+ public async Task Handle_WhenKeyExists_ReturnsChallenge()
+ {
+ byte[] expected = new byte[64];
+ _repository.ExistsAsync(Fingerprint, Arg.Any()).Returns(true);
+ _challengeService.CreateChallengeAsync(Fingerprint, Arg.Any()).Returns(expected);
+
+ byte[] result = await _handler.Handle(new GetLoginChallengeCommand(Fingerprint), CancellationToken.None);
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public async Task Handle_WhenKeyDoesNotExist_ThrowsNotFoundException()
+ {
+ _repository.ExistsAsync(Fingerprint, Arg.Any()).Returns(false);
+
+ await Assert.ThrowsAsync(() =>
+ _handler.Handle(new GetLoginChallengeCommand(Fingerprint), CancellationToken.None));
+
+ await _challengeService.DidNotReceive().CreateChallengeAsync(Arg.Any(), Arg.Any());
+ }
+}
diff --git a/Tests/Application/RegisterCommandHandlerTests.cs b/Tests/Application/RegisterCommandHandlerTests.cs
new file mode 100644
index 0000000..28b0053
--- /dev/null
+++ b/Tests/Application/RegisterCommandHandlerTests.cs
@@ -0,0 +1,58 @@
+using Application.Commands;
+using Application.Exceptions;
+using Application.Handlers.Auth;
+using Application.Interfaces;
+using Domain;
+using NSubstitute;
+
+namespace Tests.Application;
+
+public sealed class RegisterCommandHandlerTests
+{
+ private readonly IPublicKeySecurityService _security = Substitute.For();
+ private readonly IPublicKeyRepository _repository = Substitute.For();
+ private readonly RegisterCommandHandler _handler;
+
+ private static readonly string ValidFingerprint = new('a', 128);
+ private static readonly byte[] ValidDer = new byte[256];
+ private static readonly string ValidDerBase64 = Convert.ToBase64String(ValidDer);
+
+ public RegisterCommandHandlerTests()
+ {
+ _handler = new RegisterCommandHandler(_security, _repository);
+ _security.ComputeFingerprintSha512(Arg.Any()).Returns(ValidFingerprint);
+ }
+
+ [Fact]
+ public async Task Handle_WithValidCommand_ReturnsResult()
+ {
+ _repository.ExistsAsync(ValidFingerprint, Arg.Any()).Returns(false);
+
+ RegisterResult result = await _handler.Handle(
+ new RegisterCommand(ValidDerBase64, "Alice", 1111),
+ CancellationToken.None);
+
+ Assert.Equal(ValidFingerprint, result.FingerprintSha512);
+ Assert.Equal("Alice", result.UserName);
+ Assert.Equal(1111u, result.UserTag);
+ await _repository.Received(1).AddAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_WithDuplicateKey_ThrowsConflictException()
+ {
+ _repository.ExistsAsync(ValidFingerprint, Arg.Any()).Returns(true);
+
+ await Assert.ThrowsAsync(() =>
+ _handler.Handle(new RegisterCommand(ValidDerBase64, "Alice", 1111), CancellationToken.None));
+
+ await _repository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_WithInvalidBase64_ThrowsValidationException()
+ {
+ await Assert.ThrowsAsync(() =>
+ _handler.Handle(new RegisterCommand("not-valid-base64!!!", "Alice", 1111), CancellationToken.None));
+ }
+}
diff --git a/Tests/Application/SearchPublicKeysQueryHandlerTests.cs b/Tests/Application/SearchPublicKeysQueryHandlerTests.cs
new file mode 100644
index 0000000..5ee3b58
--- /dev/null
+++ b/Tests/Application/SearchPublicKeysQueryHandlerTests.cs
@@ -0,0 +1,52 @@
+using Application.DTOs;
+using Application.Exceptions;
+using Application.Handlers.PublicKeys;
+using Application.Interfaces;
+using Application.Queries;
+using NSubstitute;
+
+namespace Tests.Application;
+
+public sealed class SearchPublicKeysQueryHandlerTests
+{
+ private readonly IPublicKeyRepository _repository = Substitute.For();
+ private readonly SearchPublicKeysQueryHandler _handler;
+
+ public SearchPublicKeysQueryHandlerTests()
+ {
+ _handler = new SearchPublicKeysQueryHandler(_repository);
+ }
+
+ [Fact]
+ public async Task Handle_WithShortQuery_ThrowsValidationException()
+ {
+ await Assert.ThrowsAsync(() =>
+ _handler.Handle(new SearchPublicKeysQuery("a", null, null), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Handle_WithValidQuery_ClampsLimit()
+ {
+ _repository.SearchAsync(Arg.Any(), Arg.Any(), 100, Arg.Any())
+ .Returns(new List());
+
+ await _handler.Handle(new SearchPublicKeysQuery(" alice ", null, 999), CancellationToken.None);
+
+ await _repository.Received(1).SearchAsync(Arg.Any(), null, 100, Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_EscapesWildcardCharacters()
+ {
+ _repository.SearchAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new List());
+
+ await _handler.Handle(new SearchPublicKeysQuery("al%ice_test", null, null), CancellationToken.None);
+
+ await _repository.Received(1).SearchAsync(
+ Arg.Is(s => s.Contains("\\%") && s.Contains("\\_")),
+ null,
+ Arg.Any(),
+ Arg.Any());
+ }
+}
diff --git a/Tests/Application/SendMessageCommandHandlerTests.cs b/Tests/Application/SendMessageCommandHandlerTests.cs
new file mode 100644
index 0000000..2c67724
--- /dev/null
+++ b/Tests/Application/SendMessageCommandHandlerTests.cs
@@ -0,0 +1,63 @@
+using Application.Commands;
+using Application.Exceptions;
+using Application.Handlers.Messages;
+using Application.Interfaces;
+using Domain;
+using NSubstitute;
+
+namespace Tests.Application;
+
+public sealed class SendMessageCommandHandlerTests
+{
+ private readonly IKeyExchangeRepository _keyExchangeRepo = Substitute.For();
+ private readonly IMessageRepository _messageRepo = Substitute.For();
+ private readonly ISyncNotifier _notifier = Substitute.For();
+ private readonly SendMessageCommandHandler _handler;
+
+ private static readonly string FromFingerprint = new('a', 128);
+ private static readonly string ToFingerprint = new('b', 128);
+ private static readonly string MessageHash = new('c', 128);
+
+ public SendMessageCommandHandlerTests()
+ {
+ _handler = new SendMessageCommandHandler(_keyExchangeRepo, _messageRepo, _notifier);
+ }
+
+ [Fact]
+ public async Task Handle_WithValidCommandAndExistingKeyExchange_PersistsAndNotifies()
+ {
+ string contentBase64 = Convert.ToBase64String([1, 2, 3]);
+ _keyExchangeRepo.ExistsAsync(FromFingerprint, ToFingerprint, Arg.Any()).Returns(true);
+
+ var result = await _handler.Handle(
+ new SendMessageCommand(FromFingerprint, ToFingerprint, contentBase64, MessageHash),
+ CancellationToken.None);
+
+ Assert.Equal(FromFingerprint, result.FromPublicKey);
+ Assert.Equal(ToFingerprint, result.ToPublicKey);
+ await _messageRepo.Received(1).AddAsync(Arg.Any(), Arg.Any());
+ _notifier.Received(1).NotifyMessage(FromFingerprint, ToFingerprint);
+ }
+
+ [Fact]
+ public async Task Handle_WithoutKeyExchange_ThrowsValidationException()
+ {
+ _keyExchangeRepo.ExistsAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(false);
+
+ await Assert.ThrowsAsync(() =>
+ _handler.Handle(
+ new SendMessageCommand(FromFingerprint, ToFingerprint, Convert.ToBase64String([1, 2, 3]), MessageHash),
+ CancellationToken.None));
+
+ await _messageRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_WithInvalidBase64_ThrowsValidationException()
+ {
+ await Assert.ThrowsAsync(() =>
+ _handler.Handle(
+ new SendMessageCommand(FromFingerprint, ToFingerprint, "not-base64!!!", MessageHash),
+ CancellationToken.None));
+ }
+}
diff --git a/Tests/Domain/MessageTests.cs b/Tests/Domain/MessageTests.cs
new file mode 100644
index 0000000..bfd1891
--- /dev/null
+++ b/Tests/Domain/MessageTests.cs
@@ -0,0 +1,59 @@
+using Domain;
+
+namespace Tests.DomainTests;
+
+public sealed class MessageTests
+{
+ private static readonly string ValidFrom = new('a', 128);
+ private static readonly string ValidTo = new('b', 128);
+ private static readonly byte[] ValidContent = [1, 2, 3];
+ private static readonly string ValidHash = new('c', 128);
+
+ [Fact]
+ public void Constructor_WithValidArgs_CreatesMessage()
+ {
+ Message msg = new(ValidFrom, ValidTo, ValidContent, ValidHash);
+
+ Assert.Equal(ValidFrom, msg.FromPublicKey);
+ Assert.Equal(ValidTo, msg.ToPublicKey);
+ Assert.Equal(ValidHash, msg.MessageHash);
+ Assert.True(msg.CreatedAt > DateTime.MinValue);
+ }
+
+ [Fact]
+ public void Constructor_WithCustomCreatedAt_UsesProvidedTimestamp()
+ {
+ DateTime stamp = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ Message msg = new(ValidFrom, ValidTo, ValidContent, ValidHash, stamp);
+
+ Assert.Equal(stamp, msg.CreatedAt);
+ }
+
+ [Fact]
+ public void Constructor_WithSameFromAndTo_Throws()
+ {
+ Assert.Throws(() =>
+ new Message(ValidFrom, ValidFrom, ValidContent, ValidHash));
+ }
+
+ [Fact]
+ public void Constructor_WithEmptyContent_Throws()
+ {
+ Assert.Throws(() =>
+ new Message(ValidFrom, ValidTo, [], ValidHash));
+ }
+
+ [Fact]
+ public void Constructor_WithInvalidHash_Throws()
+ {
+ Assert.Throws(() =>
+ new Message(ValidFrom, ValidTo, ValidContent, "not-128-chars"));
+ }
+
+ [Fact]
+ public void Constructor_WithShortFingerprint_Throws()
+ {
+ Assert.Throws(() =>
+ new Message("short", ValidTo, ValidContent, ValidHash));
+ }
+}
diff --git a/Tests/Domain/PublicKeyTests.cs b/Tests/Domain/PublicKeyTests.cs
new file mode 100644
index 0000000..c831521
--- /dev/null
+++ b/Tests/Domain/PublicKeyTests.cs
@@ -0,0 +1,78 @@
+using Domain;
+
+namespace Tests.DomainTests;
+
+public sealed class PublicKeyTests
+{
+ private static readonly byte[] ValidDer = new byte[256];
+ private static readonly string ValidFingerprint = new string('a', 128);
+ private const string ValidUserName = "TestUser";
+ private const uint ValidTag = 1234;
+
+ [Fact]
+ public void Constructor_WithValidArgs_CreatesPublicKey()
+ {
+ PublicKey pk = new(ValidDer, ValidFingerprint, ValidUserName, ValidTag);
+
+ Assert.Equal(ValidFingerprint, pk.FingerprintSha512);
+ Assert.Equal(ValidUserName, pk.UserName);
+ Assert.Equal(ValidTag, pk.UserTag);
+ Assert.True(pk.CreatedAt > DateTime.MinValue);
+ }
+
+ [Fact]
+ public void Constructor_WithEmptyDer_Throws()
+ {
+ Assert.Throws(() =>
+ new PublicKey([], ValidFingerprint, ValidUserName, ValidTag));
+ }
+
+ [Theory]
+ [InlineData("ab")]
+ [InlineData("")]
+ [InlineData("xy")]
+ public void Constructor_WithShortUserName_Throws(string name)
+ {
+ Assert.Throws(() =>
+ new PublicKey(ValidDer, ValidFingerprint, name, ValidTag));
+ }
+
+ [Fact]
+ public void Constructor_WithUserNameTooLong_Throws()
+ {
+ string longName = new('a', 33);
+ Assert.Throws(() =>
+ new PublicKey(ValidDer, ValidFingerprint, longName, ValidTag));
+ }
+
+ [Fact]
+ public void Constructor_WithUserNameContainingSpaces_Throws()
+ {
+ Assert.Throws(() =>
+ new PublicKey(ValidDer, ValidFingerprint, "user name", ValidTag));
+ }
+
+ [Theory]
+ [InlineData(0u)]
+ [InlineData(100000u)]
+ public void Constructor_WithInvalidTag_Throws(uint tag)
+ {
+ Assert.Throws(() =>
+ new PublicKey(ValidDer, ValidFingerprint, ValidUserName, tag));
+ }
+
+ [Fact]
+ public void Constructor_WithFingerprintNot128Chars_Throws()
+ {
+ Assert.Throws(() =>
+ new PublicKey(ValidDer, "short", ValidUserName, ValidTag));
+ }
+
+ [Fact]
+ public void Constructor_WithNonHexFingerprint_Throws()
+ {
+ string nonHex = new('z', 128);
+ Assert.Throws(() =>
+ new PublicKey(ValidDer, nonHex, ValidUserName, ValidTag));
+ }
+}
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
new file mode 100644
index 0000000..1a32493
--- /dev/null
+++ b/Tests/Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file