From 2adf9212fef75830fd4f488a454d051ef12ca712 Mon Sep 17 00:00:00 2001 From: Patryk Pszeniczny Date: Thu, 4 Jun 2026 20:09:33 +0200 Subject: [PATCH] add exception classes and command handlers for authentication and messaging --- .idea/.idea.Messager/.idea/.gitignore | 15 + API/API.csproj | 2 + API/Contracts/AuthContracts.cs | 2 +- API/Contracts/KeyExchangeContracts.cs | 2 +- API/Contracts/MessageContracts.cs | 2 +- API/Contracts/PublicKeyContracts.cs | 2 +- API/Contracts/SyncContracts.cs | 2 +- API/Controllers/AuthController.cs | 63 +++ API/Controllers/KeyExchangesController.cs | 52 +++ API/Controllers/MessagesController.cs | 52 +++ API/Controllers/PublicKeysController.cs | 37 ++ API/Controllers/SyncController.cs | 175 ++++++++ API/DependencyInjection.cs | 86 ++++ API/Endpoints/AuthEndpoints.cs | 116 ------ API/Endpoints/EndpointHelpers.cs | 23 -- API/Endpoints/KeyExchangeEndpoints.cs | 113 ------ API/Endpoints/MessageEndpoints.cs | 103 ----- API/Endpoints/PublicKeyEndpoints.cs | 54 --- API/Endpoints/SyncEndpoints.cs | 378 ------------------ API/Extensions/ClaimsPrincipalExtensions.cs | 22 + API/Extensions/DtoMappingExtensions.cs | 19 + API/Extensions/WebApplicationExtensions.cs | 27 ++ API/Middleware/AppExceptionHandler.cs | 38 ++ API/Middleware/CorrelationIdMiddleware.cs | 22 + API/Program.cs | 150 ++----- API/Realtime/SyncNotificationHub.cs | 78 ++-- API/Realtime/SyncNotificationHubAdapter.cs | 12 + API/Security/JwtTokenIssuer.cs | 14 +- Application/Application.csproj | 6 + Application/Behaviors/ValidationBehavior.cs | 28 ++ .../Commands/GetLoginChallengeCommand.cs | 6 + Application/Commands/LoginCommand.cs | 6 + Application/Commands/RegisterCommand.cs | 8 + .../Commands/SendKeyExchangeCommand.cs | 9 + Application/Commands/SendMessageCommand.cs | 10 + Application/DTOs/KeyExchangeDto.cs | 7 + Application/DTOs/MessageDto.cs | 8 + Application/DTOs/PublicKeyProfileDto.cs | 7 + Application/DTOs/SyncDeltaDto.cs | 7 + Application/DependencyInjection.cs | 24 ++ Application/Exceptions/AppException.cs | 3 + Application/Exceptions/ConflictException.cs | 3 + Application/Exceptions/NotFoundException.cs | 3 + .../Exceptions/UnauthorizedException.cs | 3 + Application/Exceptions/ValidationException.cs | 3 + .../Auth/GetLoginChallengeCommandHandler.cs | 20 + .../Handlers/Auth/LoginCommandHandler.cs | 31 ++ .../Handlers/Auth/RegisterCommandHandler.cs | 38 ++ .../Handlers/GetKeyExchangesHandler.cs | 22 - .../Handlers/GetLoginChallengeHandler.cs | 13 - Application/Handlers/GetMessagesHandler.cs | 22 - .../GetKeyExchangesQueryHandler.cs | 18 + .../SendKeyExchangeCommandHandler.cs | 37 ++ Application/Handlers/LoginHandler.cs | 15 - .../Messages/GetMessagesQueryHandler.cs | 18 + .../Messages/SendMessageCommandHandler.cs | 47 +++ .../SearchPublicKeysQueryHandler.cs | 30 ++ Application/Handlers/RegisterHandler.cs | 27 -- .../Handlers/SendKeyExchangeHandler.cs | 16 - Application/Handlers/SendMessageHandler.cs | 22 - .../Handlers/Sync/GetSyncDeltaQueryHandler.cs | 42 ++ Application/Interfaces/ICurrentPublicKey.cs | 6 - .../Interfaces/IKeyExchangeRepository.cs | 25 ++ .../Interfaces/ILoginChallengeService.cs | 6 +- Application/Interfaces/ILoginService.cs | 8 +- Application/Interfaces/IMessageRepository.cs | 23 ++ .../Interfaces/IPublicKeyRepository.cs | 9 +- .../Interfaces/IPublicKeySecurityService.cs | 3 +- Application/Interfaces/ISyncNotifier.cs | 7 + Application/Queries/GetKeyExchangesQuery.cs | 10 + Application/Queries/GetMessagesQuery.cs | 10 + Application/Queries/GetSyncDeltaQuery.cs | 10 + Application/Queries/SearchPublicKeysQuery.cs | 7 + .../Auth/GetLoginChallengeCommandValidator.cs | 15 + .../Validators/Auth/LoginCommandValidator.cs | 18 + .../Auth/RegisterCommandValidator.cs | 23 ++ .../SendKeyExchangeCommandValidator.cs | 17 + .../Messages/SendMessageCommandValidator.cs | 22 + .../SearchPublicKeysQueryValidator.cs | 18 + Domain/BaseEntity.cs | 10 +- Domain/Fingerprint.cs | 17 + Domain/KeyExchange.cs | 45 +-- Domain/Message.cs | 65 +-- Domain/PublicKey.cs | 143 +------ Infrastructure/DependencyInjection.cs | 11 +- .../Services/CurrentPublicKeyAccessor.cs | 22 - .../Services/KeyExchangeRepository.cs | 91 +++++ .../Services/LoginChallengeService.cs | 49 ++- Infrastructure/Services/LoginService.cs | 50 ++- Infrastructure/Services/MessageRepository.cs | 73 ++++ .../Services/PublicKeyRepository.cs | 98 +++-- .../Services/PublicKeySecurityService.cs | 24 +- Messager.slnx | 1 + .../GetLoginChallengeCommandHandlerTests.cs | 44 ++ .../RegisterCommandHandlerTests.cs | 58 +++ .../SearchPublicKeysQueryHandlerTests.cs | 52 +++ .../SendMessageCommandHandlerTests.cs | 63 +++ Tests/Domain/MessageTests.cs | 59 +++ Tests/Domain/PublicKeyTests.cs | 78 ++++ Tests/Tests.csproj | 27 ++ 100 files changed, 2071 insertions(+), 1466 deletions(-) create mode 100644 .idea/.idea.Messager/.idea/.gitignore create mode 100644 API/Controllers/AuthController.cs create mode 100644 API/Controllers/KeyExchangesController.cs create mode 100644 API/Controllers/MessagesController.cs create mode 100644 API/Controllers/PublicKeysController.cs create mode 100644 API/Controllers/SyncController.cs create mode 100644 API/DependencyInjection.cs delete mode 100644 API/Endpoints/AuthEndpoints.cs delete mode 100644 API/Endpoints/EndpointHelpers.cs delete mode 100644 API/Endpoints/KeyExchangeEndpoints.cs delete mode 100644 API/Endpoints/MessageEndpoints.cs delete mode 100644 API/Endpoints/PublicKeyEndpoints.cs delete mode 100644 API/Endpoints/SyncEndpoints.cs create mode 100644 API/Extensions/ClaimsPrincipalExtensions.cs create mode 100644 API/Extensions/DtoMappingExtensions.cs create mode 100644 API/Extensions/WebApplicationExtensions.cs create mode 100644 API/Middleware/AppExceptionHandler.cs create mode 100644 API/Middleware/CorrelationIdMiddleware.cs create mode 100644 API/Realtime/SyncNotificationHubAdapter.cs create mode 100644 Application/Behaviors/ValidationBehavior.cs create mode 100644 Application/Commands/GetLoginChallengeCommand.cs create mode 100644 Application/Commands/LoginCommand.cs create mode 100644 Application/Commands/RegisterCommand.cs create mode 100644 Application/Commands/SendKeyExchangeCommand.cs create mode 100644 Application/Commands/SendMessageCommand.cs create mode 100644 Application/DTOs/KeyExchangeDto.cs create mode 100644 Application/DTOs/MessageDto.cs create mode 100644 Application/DTOs/PublicKeyProfileDto.cs create mode 100644 Application/DTOs/SyncDeltaDto.cs create mode 100644 Application/DependencyInjection.cs create mode 100644 Application/Exceptions/AppException.cs create mode 100644 Application/Exceptions/ConflictException.cs create mode 100644 Application/Exceptions/NotFoundException.cs create mode 100644 Application/Exceptions/UnauthorizedException.cs create mode 100644 Application/Exceptions/ValidationException.cs create mode 100644 Application/Handlers/Auth/GetLoginChallengeCommandHandler.cs create mode 100644 Application/Handlers/Auth/LoginCommandHandler.cs create mode 100644 Application/Handlers/Auth/RegisterCommandHandler.cs delete mode 100644 Application/Handlers/GetKeyExchangesHandler.cs delete mode 100644 Application/Handlers/GetLoginChallengeHandler.cs delete mode 100644 Application/Handlers/GetMessagesHandler.cs create mode 100644 Application/Handlers/KeyExchanges/GetKeyExchangesQueryHandler.cs create mode 100644 Application/Handlers/KeyExchanges/SendKeyExchangeCommandHandler.cs delete mode 100644 Application/Handlers/LoginHandler.cs create mode 100644 Application/Handlers/Messages/GetMessagesQueryHandler.cs create mode 100644 Application/Handlers/Messages/SendMessageCommandHandler.cs create mode 100644 Application/Handlers/PublicKeys/SearchPublicKeysQueryHandler.cs delete mode 100644 Application/Handlers/RegisterHandler.cs delete mode 100644 Application/Handlers/SendKeyExchangeHandler.cs delete mode 100644 Application/Handlers/SendMessageHandler.cs create mode 100644 Application/Handlers/Sync/GetSyncDeltaQueryHandler.cs delete mode 100644 Application/Interfaces/ICurrentPublicKey.cs create mode 100644 Application/Interfaces/IKeyExchangeRepository.cs create mode 100644 Application/Interfaces/IMessageRepository.cs create mode 100644 Application/Interfaces/ISyncNotifier.cs create mode 100644 Application/Queries/GetKeyExchangesQuery.cs create mode 100644 Application/Queries/GetMessagesQuery.cs create mode 100644 Application/Queries/GetSyncDeltaQuery.cs create mode 100644 Application/Queries/SearchPublicKeysQuery.cs create mode 100644 Application/Validators/Auth/GetLoginChallengeCommandValidator.cs create mode 100644 Application/Validators/Auth/LoginCommandValidator.cs create mode 100644 Application/Validators/Auth/RegisterCommandValidator.cs create mode 100644 Application/Validators/KeyExchanges/SendKeyExchangeCommandValidator.cs create mode 100644 Application/Validators/Messages/SendMessageCommandValidator.cs create mode 100644 Application/Validators/PublicKeys/SearchPublicKeysQueryValidator.cs create mode 100644 Domain/Fingerprint.cs delete mode 100644 Infrastructure/Services/CurrentPublicKeyAccessor.cs create mode 100644 Infrastructure/Services/KeyExchangeRepository.cs create mode 100644 Infrastructure/Services/MessageRepository.cs create mode 100644 Tests/Application/GetLoginChallengeCommandHandlerTests.cs create mode 100644 Tests/Application/RegisterCommandHandlerTests.cs create mode 100644 Tests/Application/SearchPublicKeysQueryHandlerTests.cs create mode 100644 Tests/Application/SendMessageCommandHandlerTests.cs create mode 100644 Tests/Domain/MessageTests.cs create mode 100644 Tests/Domain/PublicKeyTests.cs create mode 100644 Tests/Tests.csproj 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