🐛 Fix something
This commit is contained in:
@@ -111,7 +111,7 @@ public class DysonTokenAuthHandler(
|
||||
};
|
||||
}
|
||||
|
||||
var authHeader = request.Headers.Authorization.ToString();
|
||||
var authHeader = NormalizeAuthHeader(request.Headers.Authorization.ToString());
|
||||
if (!string.IsNullOrEmpty(authHeader))
|
||||
{
|
||||
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -170,4 +170,18 @@ public class DysonTokenAuthHandler(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeAuthHeader(string raw)
|
||||
{
|
||||
var value = raw?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
|
||||
if (value.Length >= 2 && value[0] == '[' && value[^1] == ']')
|
||||
value = value[1..^1].Trim();
|
||||
|
||||
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
|
||||
value = value[1..^1].Trim();
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using DysonNetwork.Pass.Handlers;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Extensions;
|
||||
using SystemClock = NodaTime.SystemClock;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
public static class AuthConstants
|
||||
{
|
||||
public const string SchemeName = "DysonToken";
|
||||
public const string TokenQueryParamName = "tk";
|
||||
public const string CookieTokenName = "AuthToken";
|
||||
}
|
||||
|
||||
public enum TokenType
|
||||
{
|
||||
AuthKey,
|
||||
ApiKey,
|
||||
OidcKey,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public class TokenInfo
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public TokenType Type { get; set; } = TokenType.Unknown;
|
||||
}
|
||||
|
||||
public class DysonTokenAuthOptions : AuthenticationSchemeOptions;
|
||||
|
||||
public class DysonTokenAuthHandler(
|
||||
IOptionsMonitor<DysonTokenAuthOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
TokenAuthService token,
|
||||
FlushBufferService fbs
|
||||
)
|
||||
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var tokenInfo = _ExtractToken(Request);
|
||||
|
||||
if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token))
|
||||
return AuthenticateResult.Fail("No token was provided.");
|
||||
|
||||
try
|
||||
{
|
||||
// Get client IP address
|
||||
var ipAddress = Context.GetClientIpAddress();
|
||||
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress);
|
||||
if (!valid || session is null)
|
||||
return AuthenticateResult.Fail(message ?? "Authentication failed.");
|
||||
|
||||
// Store user and session in the HttpContext.Items for easy access in controllers
|
||||
Context.Items["CurrentUser"] = session.Account;
|
||||
Context.Items["CurrentSession"] = session;
|
||||
Context.Items["CurrentTokenType"] = tokenInfo.Type.ToString();
|
||||
|
||||
// Create claims from the session
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new("user_id", session.Account.Id.ToString()),
|
||||
new("session_id", session.Id.ToString()),
|
||||
new("token_type", tokenInfo.Type.ToString())
|
||||
};
|
||||
|
||||
// Add scopes as claims
|
||||
session.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
|
||||
// Add superuser claim if applicable
|
||||
if (session.Account.IsSuperuser)
|
||||
claims.Add(new Claim("is_superuser", "1"));
|
||||
|
||||
// Create the identity and principal
|
||||
var identity = new ClaimsIdentity(claims, AuthConstants.SchemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
|
||||
|
||||
var lastInfo = new LastActiveInfo
|
||||
{
|
||||
Account = session.Account,
|
||||
Session = session,
|
||||
SeenAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
};
|
||||
fbs.Enqueue(lastInfo);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return AuthenticateResult.Fail($"Authentication failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private TokenInfo? _ExtractToken(HttpRequest request)
|
||||
{
|
||||
// Check for token in query parameters
|
||||
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = queryToken.ToString(),
|
||||
Type = TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Check for token in Authorization header
|
||||
var authHeader = request.Headers.Authorization.ToString();
|
||||
if (!string.IsNullOrEmpty(authHeader))
|
||||
{
|
||||
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var tokenText = authHeader["Bearer ".Length..].Trim();
|
||||
var parts = tokenText.Split('.');
|
||||
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = tokenText,
|
||||
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
|
||||
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = authHeader["AtField ".Length..].Trim(),
|
||||
Type = TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
|
||||
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = authHeader["AkField ".Length..].Trim(),
|
||||
Type = TokenType.ApiKey
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for token in cookies
|
||||
if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = cookieToken,
|
||||
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Extensions;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
public class AuthServiceGrpc(
|
||||
TokenAuthService token,
|
||||
AuthService auth
|
||||
)
|
||||
: DyAuthService.DyAuthServiceBase
|
||||
{
|
||||
public override async Task<DyAuthenticateResponse> Authenticate(
|
||||
DyAuthenticateRequest request,
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress);
|
||||
if (!valid || session is null)
|
||||
return new DyAuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
|
||||
|
||||
return new DyAuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
|
||||
}
|
||||
|
||||
public override async Task<DyValidateResponse> ValidatePin(DyValidatePinRequest request, ServerCallContext context)
|
||||
{
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
var valid = await auth.ValidatePinCode(accountId, request.Pin);
|
||||
return new DyValidateResponse { Valid = valid };
|
||||
}
|
||||
|
||||
public override async Task<DyValidateResponse> ValidateCaptcha(DyValidateCaptchaRequest request, ServerCallContext context)
|
||||
{
|
||||
var valid = await auth.ValidateCaptcha(request.Token);
|
||||
return new DyValidateResponse { Valid = valid };
|
||||
}
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SnAuthSession = DysonNetwork.Shared.Models.SnAuthSession;
|
||||
|
||||
namespace DysonNetwork.Pass.E2EE;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/e2ee")]
|
||||
[Authorize]
|
||||
public class E2eeController(IGroupE2eeModule e2eeModule) : ControllerBase
|
||||
{
|
||||
private const string AbilityHeader = "X-Client-Ability";
|
||||
private const string MlsAbilityToken = "chat-mls-v1";
|
||||
private static string? ResolveDeviceId(SnAuthSession session) => session.Client?.DeviceId;
|
||||
private bool HasAbility(string token)
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(AbilityHeader, out var rawValue)) return false;
|
||||
foreach (var raw in rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) continue;
|
||||
var tokens = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (tokens.Any(x => string.Equals(x, token, StringComparison.Ordinal)))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private ActionResult? EnsureMlsAbility()
|
||||
{
|
||||
if (HasAbility(MlsAbilityToken)) return null;
|
||||
return StatusCode(409, new
|
||||
{
|
||||
code = "e2ee.mls_ability_required",
|
||||
error = $"Missing required ability header: {AbilityHeader}: {MlsAbilityToken}"
|
||||
});
|
||||
}
|
||||
|
||||
private ActionResult LegacyEndpointRemoved()
|
||||
{
|
||||
return StatusCode(410, new
|
||||
{
|
||||
code = "e2ee.legacy_endpoint_removed",
|
||||
error = "Legacy E2EE endpoint removed. Use /api/e2ee/mls/* endpoints."
|
||||
});
|
||||
}
|
||||
|
||||
public class UploadKeyBundleBody
|
||||
{
|
||||
[Required] [MaxLength(32)] public string Algorithm { get; set; } = "x25519";
|
||||
[Required] public byte[] IdentityKey { get; set; } = [];
|
||||
public int? SignedPreKeyId { get; set; }
|
||||
[Required] public byte[] SignedPreKey { get; set; } = [];
|
||||
[Required] public byte[] SignedPreKeySignature { get; set; } = [];
|
||||
public DateTimeOffset? SignedPreKeyExpiresAt { get; set; }
|
||||
public List<OneTimePreKeyBody>? OneTimePreKeys { get; set; }
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
}
|
||||
|
||||
public class UploadDeviceBundleBody : UploadKeyBundleBody
|
||||
{
|
||||
[MaxLength(1024)] public string? DeviceLabel { get; set; }
|
||||
}
|
||||
|
||||
public class OneTimePreKeyBody
|
||||
{
|
||||
[Required] public int KeyId { get; set; }
|
||||
[Required] public byte[] PublicKey { get; set; } = [];
|
||||
}
|
||||
|
||||
public class EnsureSessionBody
|
||||
{
|
||||
[MaxLength(128)] public string? Hint { get; set; }
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
}
|
||||
|
||||
public class SendEnvelopeBody
|
||||
{
|
||||
[Required] public Guid RecipientId { get; set; }
|
||||
public Guid? SessionId { get; set; }
|
||||
public SnE2eeEnvelopeType Type { get; set; } = SnE2eeEnvelopeType.PairwiseMessage;
|
||||
[MaxLength(256)] public string? GroupId { get; set; }
|
||||
[MaxLength(128)] public string? ClientMessageId { get; set; }
|
||||
[Required] public byte[] Ciphertext { get; set; } = [];
|
||||
public byte[]? Header { get; set; }
|
||||
public byte[]? Signature { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
}
|
||||
|
||||
public class FanoutEnvelopeBody
|
||||
{
|
||||
[Required] public Guid RecipientAccountId { get; set; }
|
||||
public Guid? SessionId { get; set; }
|
||||
public SnE2eeEnvelopeType Type { get; set; } = SnE2eeEnvelopeType.PairwiseMessage;
|
||||
[MaxLength(256)] public string? GroupId { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public bool IncludeSenderCopy { get; set; }
|
||||
[Required] [MinLength(1)] public List<FanoutEnvelopeItemBody> Payloads { get; set; } = [];
|
||||
}
|
||||
|
||||
public class FanoutEnvelopeItemBody
|
||||
{
|
||||
[Required] [MaxLength(512)] public string RecipientDeviceId { get; set; } = null!;
|
||||
[MaxLength(128)] public string? ClientMessageId { get; set; }
|
||||
[Required] public byte[] Ciphertext { get; set; } = [];
|
||||
public byte[]? Header { get; set; }
|
||||
public byte[]? Signature { get; set; }
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
}
|
||||
|
||||
public class PublishMlsKeyPackageBody
|
||||
{
|
||||
[Required] public byte[] KeyPackage { get; set; } = [];
|
||||
[MaxLength(128)] public string Ciphersuite { get; set; } = "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519";
|
||||
[MaxLength(1024)] public string? DeviceLabel { get; set; }
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
}
|
||||
|
||||
public class BootstrapMlsGroupBody
|
||||
{
|
||||
[Required] public Guid ChatRoomId { get; set; }
|
||||
[Required] [MaxLength(256)] public string MlsGroupId { get; set; } = null!;
|
||||
[Required] public long Epoch { get; set; }
|
||||
public long StateVersion { get; set; } = 1;
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
}
|
||||
|
||||
public class CommitMlsGroupBody
|
||||
{
|
||||
[Required] public Guid ChatRoomId { get; set; }
|
||||
[Required] [MaxLength(256)] public string MlsGroupId { get; set; } = null!;
|
||||
[Required] public long Epoch { get; set; }
|
||||
[Required] [MaxLength(128)] public string Reason { get; set; } = null!;
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
}
|
||||
|
||||
public class FanoutMlsWelcomeBody
|
||||
{
|
||||
[Required] public Guid ChatRoomId { get; set; }
|
||||
[Required] [MaxLength(256)] public string MlsGroupId { get; set; } = null!;
|
||||
[Required] public Guid RecipientAccountId { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
[Required] [MinLength(1)] public List<FanoutEnvelopeItemBody> Payloads { get; set; } = [];
|
||||
}
|
||||
|
||||
public class MarkMlsReshareRequiredBody
|
||||
{
|
||||
[Required] public Guid ChatRoomId { get; set; }
|
||||
[Required] [MaxLength(256)] public string MlsGroupId { get; set; } = null!;
|
||||
[Required] public Guid TargetAccountId { get; set; }
|
||||
[Required] [MaxLength(512)] public string TargetDeviceId { get; set; } = null!;
|
||||
[Required] public long Epoch { get; set; }
|
||||
[Required] [MaxLength(128)] public string Reason { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class DistributeSenderKeyBody
|
||||
{
|
||||
[Required] [MaxLength(256)] public string GroupId { get; set; } = null!;
|
||||
[Required] [MinLength(1)] public List<SenderKeyEnvelopeBody> Items { get; set; } = [];
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
public class SenderKeyEnvelopeBody
|
||||
{
|
||||
[Required] public Guid RecipientId { get; set; }
|
||||
[Required] public byte[] Ciphertext { get; set; } = [];
|
||||
public byte[]? Header { get; set; }
|
||||
public byte[]? Signature { get; set; }
|
||||
[MaxLength(128)] public string? ClientMessageId { get; set; }
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("keys/upload")]
|
||||
public async Task<ActionResult<SnE2eeKeyBundle>> UploadKeyBundle([FromBody] UploadKeyBundleBody body)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpPut("devices/me/bundle")]
|
||||
public async Task<ActionResult<SnE2eeKeyBundle>> UploadDeviceBundle([FromBody] UploadDeviceBundleBody body)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpGet("keys/me")]
|
||||
public async Task<ActionResult<E2eePublicKeyBundleResponse>> GetMyPublicBundle()
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpGet("keys/{accountId:guid}/bundle")]
|
||||
public async Task<ActionResult<E2eePublicKeyBundleResponse>> GetPublicBundle(
|
||||
Guid accountId,
|
||||
[FromQuery] bool consumeOneTimePreKey = true
|
||||
)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpGet("keys/{accountId:guid}/devices")]
|
||||
public async Task<ActionResult<List<E2eeDevicePublicBundleResponse>>> GetPublicBundlesByDevice(
|
||||
Guid accountId,
|
||||
[FromQuery] bool consumeOneTimePreKey = true
|
||||
)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{peerId:guid}")]
|
||||
public async Task<ActionResult<SnE2eeSession>> EnsureSession(Guid peerId, [FromBody] EnsureSessionBody body)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpPost("messages")]
|
||||
public async Task<ActionResult<SnE2eeEnvelope>> SendEnvelope([FromBody] SendEnvelopeBody body)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpPost("messages/fanout")]
|
||||
public async Task<ActionResult<List<SnE2eeEnvelope>>> SendFanout([FromBody] FanoutEnvelopeBody body)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpPost("groups/sender-key/distribute")]
|
||||
public async Task<ActionResult<object>> DistributeSenderKey([FromBody] DistributeSenderKeyBody body)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpGet("messages/pending")]
|
||||
public async Task<ActionResult<List<SnE2eeEnvelope>>> GetPendingMessages([FromQuery] int take = 100)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpGet("envelopes/pending")]
|
||||
public async Task<ActionResult<List<SnE2eeEnvelope>>> GetPendingByDevice(
|
||||
[FromQuery(Name = "device_id")] string? deviceId,
|
||||
[FromQuery] int take = 100
|
||||
)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpPost("messages/{envelopeId:guid}/ack")]
|
||||
public async Task<ActionResult<SnE2eeEnvelope>> AckMessage(Guid envelopeId)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpPost("envelopes/{envelopeId:guid}/ack")]
|
||||
public async Task<ActionResult<SnE2eeEnvelope>> AckMessageByDevice(
|
||||
Guid envelopeId,
|
||||
[FromQuery(Name = "device_id")] string? deviceId
|
||||
)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpPost("devices/{deviceId}/revoke")]
|
||||
public async Task<ActionResult> RevokeDevice(string deviceId)
|
||||
{
|
||||
return LegacyEndpointRemoved();
|
||||
}
|
||||
|
||||
[HttpPut("mls/devices/me/key-packages")]
|
||||
public async Task<ActionResult<SnMlsKeyPackage>> PublishMlsKeyPackage([FromBody] PublishMlsKeyPackageBody body)
|
||||
{
|
||||
if (EnsureMlsAbility() is { } abilityError) return abilityError;
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as SnAccount;
|
||||
var currentSession = HttpContext.Items["CurrentSession"] as SnAuthSession;
|
||||
if (currentUser is null || currentSession is null) return Unauthorized();
|
||||
|
||||
var deviceId = ResolveDeviceId(currentSession);
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
return BadRequest("Current session device id is missing.");
|
||||
|
||||
var result = await e2eeModule.PublishMlsKeyPackageAsync(currentUser.Id, deviceId, body.DeviceLabel,
|
||||
new PublishMlsKeyPackageRequest(body.KeyPackage, body.Ciphersuite, body.Meta));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("mls/keys/{accountId:guid}/devices")]
|
||||
public async Task<ActionResult<List<MlsDeviceKeyPackageResponse>>> ListMlsKeyPackagesByDevice(
|
||||
Guid accountId,
|
||||
[FromQuery] bool consume = true
|
||||
)
|
||||
{
|
||||
if (EnsureMlsAbility() is { } abilityError) return abilityError;
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as SnAccount;
|
||||
if (currentUser is null) return Unauthorized();
|
||||
|
||||
var result = await e2eeModule.ListMlsDeviceKeyPackagesAsync(accountId, currentUser.Id, consume);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("mls/groups/{roomId:guid}/bootstrap")]
|
||||
public async Task<ActionResult<SnMlsGroupState>> BootstrapMlsGroup(Guid roomId, [FromBody] BootstrapMlsGroupBody body)
|
||||
{
|
||||
if (EnsureMlsAbility() is { } abilityError) return abilityError;
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as SnAccount;
|
||||
if (currentUser is null) return Unauthorized();
|
||||
if (roomId != body.ChatRoomId) return BadRequest("Room id mismatch.");
|
||||
|
||||
var state = await e2eeModule.BootstrapMlsGroupAsync(currentUser.Id, new BootstrapMlsGroupRequest(
|
||||
body.ChatRoomId, body.MlsGroupId, body.Epoch, body.StateVersion, body.Meta
|
||||
));
|
||||
return Ok(state);
|
||||
}
|
||||
|
||||
[HttpPost("mls/groups/{roomId:guid}/commit")]
|
||||
public async Task<ActionResult<SnMlsGroupState>> CommitMlsGroup(Guid roomId, [FromBody] CommitMlsGroupBody body)
|
||||
{
|
||||
if (EnsureMlsAbility() is { } abilityError) return abilityError;
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as SnAccount;
|
||||
if (currentUser is null) return Unauthorized();
|
||||
if (roomId != body.ChatRoomId) return BadRequest("Room id mismatch.");
|
||||
|
||||
var state = await e2eeModule.CommitMlsGroupAsync(currentUser.Id, new CommitMlsGroupRequest(
|
||||
body.ChatRoomId, body.MlsGroupId, body.Epoch, body.Reason, body.Meta
|
||||
));
|
||||
if (state is null) return NotFound();
|
||||
return Ok(state);
|
||||
}
|
||||
|
||||
[HttpPost("mls/groups/{roomId:guid}/welcome/fanout")]
|
||||
public async Task<ActionResult<List<SnE2eeEnvelope>>> FanoutMlsWelcome(Guid roomId, [FromBody] FanoutMlsWelcomeBody body)
|
||||
{
|
||||
if (EnsureMlsAbility() is { } abilityError) return abilityError;
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as SnAccount;
|
||||
var currentSession = HttpContext.Items["CurrentSession"] as SnAuthSession;
|
||||
if (currentUser is null || currentSession is null) return Unauthorized();
|
||||
if (roomId != body.ChatRoomId) return BadRequest("Room id mismatch.");
|
||||
|
||||
var senderDeviceId = ResolveDeviceId(currentSession);
|
||||
if (string.IsNullOrWhiteSpace(senderDeviceId))
|
||||
return BadRequest("Current session device id is missing.");
|
||||
|
||||
var result = await e2eeModule.FanoutMlsWelcomeAsync(currentUser.Id, senderDeviceId,
|
||||
new FanoutMlsWelcomeRequest(
|
||||
body.ChatRoomId,
|
||||
body.MlsGroupId,
|
||||
body.RecipientAccountId,
|
||||
body.ExpiresAt,
|
||||
body.Payloads.Select(x => new DeviceCiphertextEnvelope(
|
||||
x.RecipientDeviceId,
|
||||
x.ClientMessageId,
|
||||
x.Ciphertext,
|
||||
x.Header,
|
||||
x.Signature,
|
||||
x.Meta
|
||||
)).ToList()
|
||||
));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("mls/groups/{roomId:guid}/reshare-required")]
|
||||
public async Task<ActionResult<SnMlsDeviceMembership>> MarkMlsReshareRequired(
|
||||
Guid roomId,
|
||||
[FromBody] MarkMlsReshareRequiredBody body
|
||||
)
|
||||
{
|
||||
if (EnsureMlsAbility() is { } abilityError) return abilityError;
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as SnAccount;
|
||||
if (currentUser is null) return Unauthorized();
|
||||
if (roomId != body.ChatRoomId) return BadRequest("Room id mismatch.");
|
||||
|
||||
var result = await e2eeModule.MarkMlsReshareRequiredAsync(currentUser.Id, new MarkMlsReshareRequiredRequest(
|
||||
body.ChatRoomId, body.MlsGroupId, body.TargetAccountId, body.TargetDeviceId, body.Epoch, body.Reason
|
||||
));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("mls/messages/fanout")]
|
||||
public async Task<ActionResult<List<SnE2eeEnvelope>>> SendMlsFanout([FromBody] FanoutEnvelopeBody body)
|
||||
{
|
||||
if (EnsureMlsAbility() is { } abilityError) return abilityError;
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as SnAccount;
|
||||
var currentSession = HttpContext.Items["CurrentSession"] as SnAuthSession;
|
||||
if (currentUser is null || currentSession is null) return Unauthorized();
|
||||
|
||||
var senderDeviceId = ResolveDeviceId(currentSession);
|
||||
if (string.IsNullOrWhiteSpace(senderDeviceId))
|
||||
return BadRequest("Current session device id is missing.");
|
||||
|
||||
body.Type = SnE2eeEnvelopeType.MlsApplication;
|
||||
var envelopes = await e2eeModule.SendFanoutEnvelopesAsync(currentUser.Id, senderDeviceId,
|
||||
new SendE2eeFanoutRequest(
|
||||
body.RecipientAccountId,
|
||||
body.SessionId,
|
||||
body.Type,
|
||||
body.GroupId,
|
||||
body.ExpiresAt,
|
||||
body.IncludeSenderCopy,
|
||||
body.Payloads.Select(x => new DeviceCiphertextEnvelope(
|
||||
x.RecipientDeviceId,
|
||||
x.ClientMessageId,
|
||||
x.Ciphertext,
|
||||
x.Header,
|
||||
x.Signature,
|
||||
x.Meta
|
||||
)).ToList()
|
||||
));
|
||||
return Ok(envelopes);
|
||||
}
|
||||
|
||||
[HttpGet("mls/envelopes/pending")]
|
||||
public async Task<ActionResult<List<SnE2eeEnvelope>>> GetMlsPendingByDevice(
|
||||
[FromQuery(Name = "device_id")] string? deviceId,
|
||||
[FromQuery] int take = 100
|
||||
)
|
||||
{
|
||||
if (EnsureMlsAbility() is { } abilityError) return abilityError;
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as SnAccount;
|
||||
var currentSession = HttpContext.Items["CurrentSession"] as SnAuthSession;
|
||||
if (currentUser is null || currentSession is null) return Unauthorized();
|
||||
|
||||
var effectiveDeviceId = string.IsNullOrWhiteSpace(deviceId)
|
||||
? ResolveDeviceId(currentSession)
|
||||
: deviceId;
|
||||
if (string.IsNullOrWhiteSpace(effectiveDeviceId))
|
||||
return BadRequest("device_id is required.");
|
||||
|
||||
take = Math.Clamp(take, 1, 500);
|
||||
var envelopes = await e2eeModule.GetPendingEnvelopesByDeviceAsync(currentUser.Id, effectiveDeviceId, take);
|
||||
return Ok(envelopes);
|
||||
}
|
||||
|
||||
[HttpPost("mls/envelopes/{envelopeId:guid}/ack")]
|
||||
public async Task<ActionResult<SnE2eeEnvelope>> AckMlsEnvelope(
|
||||
Guid envelopeId,
|
||||
[FromQuery(Name = "device_id")] string? deviceId
|
||||
)
|
||||
{
|
||||
if (EnsureMlsAbility() is { } abilityError) return abilityError;
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as SnAccount;
|
||||
var currentSession = HttpContext.Items["CurrentSession"] as SnAuthSession;
|
||||
if (currentUser is null || currentSession is null) return Unauthorized();
|
||||
|
||||
var effectiveDeviceId = string.IsNullOrWhiteSpace(deviceId)
|
||||
? ResolveDeviceId(currentSession)
|
||||
: deviceId;
|
||||
if (string.IsNullOrWhiteSpace(effectiveDeviceId))
|
||||
return BadRequest("device_id is required.");
|
||||
|
||||
var message = await e2eeModule.AcknowledgeEnvelopeByDeviceAsync(currentUser.Id, effectiveDeviceId, envelopeId);
|
||||
if (message is null) return NotFound();
|
||||
return Ok(message);
|
||||
}
|
||||
|
||||
[HttpPost("mls/devices/{deviceId}/revoke")]
|
||||
public async Task<ActionResult> RevokeMlsDevice(string deviceId)
|
||||
{
|
||||
if (EnsureMlsAbility() is { } abilityError) return abilityError;
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as SnAccount;
|
||||
if (currentUser is null) return Unauthorized();
|
||||
|
||||
var revoked = await e2eeModule.RevokeDeviceAsync(currentUser.Id, deviceId);
|
||||
if (!revoked) return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,948 +0,0 @@
|
||||
using System.Data;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.E2EE;
|
||||
|
||||
public class E2EeService(
|
||||
AppDatabase db,
|
||||
AccountService accountService,
|
||||
RemoteWebSocketService ws,
|
||||
ILogger<E2EeService> logger
|
||||
) : IGroupE2eeModule
|
||||
{
|
||||
private const string PacketType = "e2ee.envelope";
|
||||
private const string LegacyDeviceId = "legacy-account";
|
||||
private const int MlsKeyPackageDailyLimitPerAccount = 10;
|
||||
private const int MlsKeyPackageRetentionDays = 30;
|
||||
private const int MaxFanoutPayloadsPerRequest = 1000;
|
||||
|
||||
public async Task<SnE2eeKeyBundle> UpsertKeyBundleAsync(Guid accountId, UpsertE2eeKeyBundleRequest request)
|
||||
=> await UpsertDeviceBundleAsync(accountId, LegacyDeviceId, "Legacy account-scoped bundle", request);
|
||||
|
||||
public async Task<SnE2eeKeyBundle> UpsertDeviceBundleAsync(
|
||||
Guid accountId,
|
||||
string deviceId,
|
||||
string? deviceLabel,
|
||||
UpsertE2eeKeyBundleRequest request
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
throw new InvalidOperationException("deviceId cannot be empty.");
|
||||
|
||||
var bundle = await db.E2eeKeyBundles
|
||||
.Include(b => b.OneTimePreKeys)
|
||||
.FirstOrDefaultAsync(b => b.AccountId == accountId && b.DeviceId == deviceId);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
bundle = new SnE2eeKeyBundle
|
||||
{
|
||||
AccountId = accountId,
|
||||
DeviceId = deviceId
|
||||
};
|
||||
db.E2eeKeyBundles.Add(bundle);
|
||||
}
|
||||
|
||||
var e2eeDevice = await db.E2eeDevices.FirstOrDefaultAsync(d =>
|
||||
d.AccountId == accountId && d.DeviceId == deviceId);
|
||||
if (e2eeDevice is null)
|
||||
{
|
||||
e2eeDevice = new SnE2eeDevice
|
||||
{
|
||||
AccountId = accountId,
|
||||
DeviceId = deviceId,
|
||||
DeviceLabel = deviceLabel
|
||||
};
|
||||
db.E2eeDevices.Add(e2eeDevice);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(deviceLabel))
|
||||
e2eeDevice.DeviceLabel = deviceLabel;
|
||||
e2eeDevice.IsRevoked = false;
|
||||
e2eeDevice.RevokedAt = null;
|
||||
}
|
||||
e2eeDevice.LastBundleAt = now;
|
||||
|
||||
bundle.Algorithm = request.Algorithm;
|
||||
bundle.IdentityKey = request.IdentityKey;
|
||||
bundle.SignedPreKeyId = request.SignedPreKeyId;
|
||||
bundle.SignedPreKey = request.SignedPreKey;
|
||||
bundle.SignedPreKeySignature = request.SignedPreKeySignature;
|
||||
bundle.SignedPreKeyExpiresAt = request.SignedPreKeyExpiresAt is null
|
||||
? null
|
||||
: Instant.FromDateTimeOffset(request.SignedPreKeyExpiresAt.Value);
|
||||
bundle.Meta = request.Meta;
|
||||
bundle.UpdatedAt = now;
|
||||
|
||||
if (request.OneTimePreKeys is { Count: > 0 })
|
||||
{
|
||||
var existingIds = bundle.OneTimePreKeys.Select(k => k.KeyId).ToHashSet();
|
||||
var newPreKeys = request.OneTimePreKeys
|
||||
.Where(k => !existingIds.Contains(k.KeyId))
|
||||
.Select(k => new SnE2eeOneTimePreKey
|
||||
{
|
||||
KeyBundle = bundle,
|
||||
AccountId = accountId,
|
||||
DeviceId = deviceId,
|
||||
KeyId = k.KeyId,
|
||||
PublicKey = k.PublicKey
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (newPreKeys.Count > 0)
|
||||
db.E2eeOneTimePreKeys.AddRange(newPreKeys);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public async Task<E2eePublicKeyBundleResponse?> GetPublicBundleAsync(Guid accountId, Guid requesterId, bool consumeOneTimePreKey)
|
||||
{
|
||||
var bundle = await db.E2eeKeyBundles
|
||||
.Include(b => b.OneTimePreKeys)
|
||||
.Where(b => b.AccountId == accountId)
|
||||
.OrderByDescending(b => b.UpdatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (bundle is null)
|
||||
return null;
|
||||
|
||||
UpsertE2eeOneTimePreKey? claimedPreKey = null;
|
||||
if (consumeOneTimePreKey)
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
|
||||
var firstAvailable = await db.E2eeOneTimePreKeys
|
||||
.Where(k => k.KeyBundleId == bundle.Id && !k.IsClaimed)
|
||||
.OrderBy(k => k.KeyId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (firstAvailable is not null)
|
||||
{
|
||||
firstAvailable.IsClaimed = true;
|
||||
firstAvailable.ClaimedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
firstAvailable.ClaimedByAccountId = requesterId;
|
||||
claimedPreKey = new UpsertE2eeOneTimePreKey(firstAvailable.KeyId, firstAvailable.PublicKey);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
}
|
||||
|
||||
return new E2eePublicKeyBundleResponse(
|
||||
bundle.AccountId,
|
||||
bundle.Algorithm,
|
||||
bundle.IdentityKey,
|
||||
bundle.SignedPreKeyId,
|
||||
bundle.SignedPreKey,
|
||||
bundle.SignedPreKeySignature,
|
||||
bundle.SignedPreKeyExpiresAt?.ToDateTimeOffset(),
|
||||
claimedPreKey,
|
||||
bundle.Meta
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<List<E2eeDevicePublicBundleResponse>> GetPublicDeviceBundlesAsync(
|
||||
Guid accountId,
|
||||
Guid requesterId,
|
||||
bool consumeOneTimePreKey
|
||||
)
|
||||
{
|
||||
var devices = await db.E2eeDevices
|
||||
.Where(d => d.AccountId == accountId && !d.IsRevoked)
|
||||
.ToListAsync();
|
||||
if (devices.Count == 0)
|
||||
return [];
|
||||
|
||||
var bundles = await db.E2eeKeyBundles
|
||||
.Where(b => b.AccountId == accountId)
|
||||
.ToListAsync();
|
||||
var bundlesByDevice = bundles.ToDictionary(b => b.DeviceId, b => b);
|
||||
|
||||
var responses = new List<E2eeDevicePublicBundleResponse>();
|
||||
foreach (var device in devices)
|
||||
{
|
||||
if (!bundlesByDevice.TryGetValue(device.DeviceId, out var bundle))
|
||||
continue;
|
||||
|
||||
UpsertE2eeOneTimePreKey? claimedPreKey = null;
|
||||
if (consumeOneTimePreKey)
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
|
||||
var firstAvailable = await db.E2eeOneTimePreKeys
|
||||
.Where(k =>
|
||||
k.KeyBundleId == bundle.Id &&
|
||||
k.AccountId == accountId &&
|
||||
k.DeviceId == device.DeviceId &&
|
||||
!k.IsClaimed)
|
||||
.OrderBy(k => k.KeyId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (firstAvailable is not null)
|
||||
{
|
||||
firstAvailable.IsClaimed = true;
|
||||
firstAvailable.ClaimedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
firstAvailable.ClaimedByAccountId = requesterId;
|
||||
claimedPreKey = new UpsertE2eeOneTimePreKey(firstAvailable.KeyId, firstAvailable.PublicKey);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await tx.CommitAsync();
|
||||
}
|
||||
|
||||
responses.Add(new E2eeDevicePublicBundleResponse(
|
||||
bundle.AccountId,
|
||||
device.DeviceId,
|
||||
device.DeviceLabel,
|
||||
bundle.Algorithm,
|
||||
bundle.IdentityKey,
|
||||
bundle.SignedPreKeyId,
|
||||
bundle.SignedPreKey,
|
||||
bundle.SignedPreKeySignature,
|
||||
bundle.SignedPreKeyExpiresAt?.ToDateTimeOffset(),
|
||||
claimedPreKey,
|
||||
bundle.Meta
|
||||
));
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
public async Task<SnMlsKeyPackage> PublishMlsKeyPackageAsync(
|
||||
Guid accountId,
|
||||
string deviceId,
|
||||
string? deviceLabel,
|
||||
PublishMlsKeyPackageRequest request
|
||||
)
|
||||
{
|
||||
if (request.KeyPackage.Length == 0)
|
||||
throw new InvalidOperationException("MLS key package cannot be empty.");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await PurgeExpiredMlsKeyPackagesAsync(accountId, now);
|
||||
var dayWindowStart = now - Duration.FromDays(1);
|
||||
var uploadedInDay = await db.MlsKeyPackages
|
||||
.Where(k => k.AccountId == accountId && k.CreatedAt >= dayWindowStart)
|
||||
.CountAsync();
|
||||
if (uploadedInDay >= MlsKeyPackageDailyLimitPerAccount)
|
||||
throw new InvalidOperationException(
|
||||
$"MLS key package daily upload limit exceeded. Max {MlsKeyPackageDailyLimitPerAccount} per 24h.");
|
||||
|
||||
var e2eeDevice = await db.E2eeDevices.FirstOrDefaultAsync(d =>
|
||||
d.AccountId == accountId && d.DeviceId == deviceId);
|
||||
if (e2eeDevice is null)
|
||||
{
|
||||
e2eeDevice = new SnE2eeDevice
|
||||
{
|
||||
AccountId = accountId,
|
||||
DeviceId = deviceId,
|
||||
DeviceLabel = deviceLabel
|
||||
};
|
||||
db.E2eeDevices.Add(e2eeDevice);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(deviceLabel))
|
||||
e2eeDevice.DeviceLabel = deviceLabel;
|
||||
e2eeDevice.IsRevoked = false;
|
||||
e2eeDevice.RevokedAt = null;
|
||||
}
|
||||
e2eeDevice.LastBundleAt = now;
|
||||
|
||||
var keyPackage = new SnMlsKeyPackage
|
||||
{
|
||||
AccountId = accountId,
|
||||
DeviceId = deviceId,
|
||||
DeviceLabel = deviceLabel,
|
||||
KeyPackage = request.KeyPackage,
|
||||
Ciphersuite = request.Ciphersuite,
|
||||
Meta = request.Meta,
|
||||
IsConsumed = false
|
||||
};
|
||||
db.MlsKeyPackages.Add(keyPackage);
|
||||
await db.SaveChangesAsync();
|
||||
return keyPackage;
|
||||
}
|
||||
|
||||
public async Task<List<MlsDeviceKeyPackageResponse>> ListMlsDeviceKeyPackagesAsync(
|
||||
Guid accountId,
|
||||
Guid requesterId,
|
||||
bool consume
|
||||
)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await PurgeExpiredMlsKeyPackagesAsync(accountId, now);
|
||||
var activeDevices = await db.E2eeDevices
|
||||
.Where(d => d.AccountId == accountId && !d.IsRevoked)
|
||||
.ToListAsync();
|
||||
var responses = new List<MlsDeviceKeyPackageResponse>();
|
||||
var dirty = false;
|
||||
|
||||
foreach (var device in activeDevices)
|
||||
{
|
||||
var package = await db.MlsKeyPackages
|
||||
.Where(k => k.AccountId == accountId && k.DeviceId == device.DeviceId && !k.IsConsumed)
|
||||
.OrderBy(k => k.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (package is null)
|
||||
{
|
||||
package = await db.MlsKeyPackages
|
||||
.Where(k => k.AccountId == accountId && k.DeviceId == device.DeviceId)
|
||||
.OrderByDescending(k => k.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
if (package is null) continue;
|
||||
|
||||
if (consume && !package.IsConsumed)
|
||||
{
|
||||
package.IsConsumed = true;
|
||||
package.ConsumedAt = now;
|
||||
package.ConsumedByAccountId = requesterId;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
responses.Add(new MlsDeviceKeyPackageResponse(
|
||||
package.AccountId,
|
||||
package.DeviceId,
|
||||
device.DeviceLabel ?? package.DeviceLabel,
|
||||
package.Ciphersuite,
|
||||
package.KeyPackage,
|
||||
package.Meta
|
||||
));
|
||||
}
|
||||
|
||||
if (dirty)
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
public async Task<SnMlsGroupState> BootstrapMlsGroupAsync(Guid accountId, BootstrapMlsGroupRequest request)
|
||||
{
|
||||
var existing = await db.MlsGroupStates
|
||||
.FirstOrDefaultAsync(s => s.ChatRoomId == request.ChatRoomId);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.MlsGroupId = request.MlsGroupId;
|
||||
existing.Epoch = request.Epoch;
|
||||
existing.StateVersion = request.StateVersion;
|
||||
existing.Meta = request.Meta;
|
||||
existing.LastCommitAt = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
var state = new SnMlsGroupState
|
||||
{
|
||||
ChatRoomId = request.ChatRoomId,
|
||||
MlsGroupId = request.MlsGroupId,
|
||||
Epoch = request.Epoch,
|
||||
StateVersion = request.StateVersion,
|
||||
LastCommitAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
Meta = request.Meta
|
||||
};
|
||||
db.MlsGroupStates.Add(state);
|
||||
await db.SaveChangesAsync();
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task<SnMlsGroupState?> CommitMlsGroupAsync(Guid accountId, CommitMlsGroupRequest request)
|
||||
{
|
||||
var state = await db.MlsGroupStates
|
||||
.FirstOrDefaultAsync(s => s.ChatRoomId == request.ChatRoomId && s.MlsGroupId == request.MlsGroupId);
|
||||
if (state is null)
|
||||
return null;
|
||||
|
||||
state.Epoch = Math.Max(state.Epoch, request.Epoch);
|
||||
state.StateVersion += 1;
|
||||
state.LastCommitAt = SystemClock.Instance.GetCurrentInstant();
|
||||
state.Meta = request.Meta is null
|
||||
? state.Meta
|
||||
: new Dictionary<string, object>(request.Meta)
|
||||
{
|
||||
["reason"] = request.Reason
|
||||
};
|
||||
await db.SaveChangesAsync();
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task<List<SnE2eeEnvelope>> FanoutMlsWelcomeAsync(
|
||||
Guid senderId,
|
||||
string senderDeviceId,
|
||||
FanoutMlsWelcomeRequest request
|
||||
)
|
||||
{
|
||||
var payloads = request.Payloads
|
||||
.Select(p => new DeviceCiphertextEnvelope(
|
||||
p.RecipientDeviceId,
|
||||
p.ClientMessageId,
|
||||
p.Ciphertext,
|
||||
p.Header,
|
||||
p.Signature,
|
||||
p.Meta is null
|
||||
? new Dictionary<string, object> { ["mls_group_id"] = request.MlsGroupId }
|
||||
: new Dictionary<string, object>(p.Meta) { ["mls_group_id"] = request.MlsGroupId }
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return await SendFanoutEnvelopesAsync(senderId, senderDeviceId, new SendE2eeFanoutRequest(
|
||||
request.RecipientAccountId,
|
||||
null,
|
||||
SnE2eeEnvelopeType.MlsWelcome,
|
||||
request.MlsGroupId,
|
||||
request.ExpiresAt,
|
||||
IncludeSenderCopy: false,
|
||||
payloads
|
||||
));
|
||||
}
|
||||
|
||||
public async Task<SnMlsDeviceMembership> MarkMlsReshareRequiredAsync(
|
||||
Guid accountId,
|
||||
MarkMlsReshareRequiredRequest request
|
||||
)
|
||||
{
|
||||
var membership = await db.MlsDeviceMemberships
|
||||
.FirstOrDefaultAsync(m =>
|
||||
m.ChatRoomId == request.ChatRoomId &&
|
||||
m.AccountId == request.TargetAccountId &&
|
||||
m.DeviceId == request.TargetDeviceId);
|
||||
if (membership is null)
|
||||
{
|
||||
membership = new SnMlsDeviceMembership
|
||||
{
|
||||
ChatRoomId = request.ChatRoomId,
|
||||
MlsGroupId = request.MlsGroupId,
|
||||
AccountId = request.TargetAccountId,
|
||||
DeviceId = request.TargetDeviceId,
|
||||
JoinedEpoch = request.Epoch,
|
||||
LastSeenEpoch = request.Epoch
|
||||
};
|
||||
db.MlsDeviceMemberships.Add(membership);
|
||||
}
|
||||
|
||||
membership.MlsGroupId = request.MlsGroupId;
|
||||
membership.LastReshareRequiredAt = SystemClock.Instance.GetCurrentInstant();
|
||||
membership.LastSeenEpoch = request.Epoch;
|
||||
await db.SaveChangesAsync();
|
||||
return membership;
|
||||
}
|
||||
|
||||
public async Task<SnE2eeSession> EnsureSessionAsync(Guid accountId, Guid peerId, EnsureE2eeSessionRequest request)
|
||||
{
|
||||
EnsurePairOrder(accountId, peerId, out var accountAId, out var accountBId);
|
||||
|
||||
var session = await db.E2eeSessions
|
||||
.FirstOrDefaultAsync(s => s.AccountAId == accountAId && s.AccountBId == accountBId);
|
||||
if (session is not null)
|
||||
{
|
||||
session.Hint = request.Hint ?? session.Hint;
|
||||
session.Meta = request.Meta ?? session.Meta;
|
||||
await db.SaveChangesAsync();
|
||||
return session;
|
||||
}
|
||||
|
||||
session = new SnE2eeSession
|
||||
{
|
||||
AccountAId = accountAId,
|
||||
AccountBId = accountBId,
|
||||
InitiatedById = accountId,
|
||||
Hint = request.Hint,
|
||||
Meta = request.Meta
|
||||
};
|
||||
db.E2eeSessions.Add(session);
|
||||
await db.SaveChangesAsync();
|
||||
return session;
|
||||
}
|
||||
|
||||
public async Task<SnE2eeEnvelope> SendEnvelopeAsync(Guid senderId, SendE2eeEnvelopeRequest request)
|
||||
{
|
||||
if (request.Ciphertext.Length == 0)
|
||||
throw new InvalidOperationException("Ciphertext cannot be empty.");
|
||||
if (!string.IsNullOrWhiteSpace(request.ClientMessageId))
|
||||
{
|
||||
var existing = await db.E2eeEnvelopes.FirstOrDefaultAsync(e =>
|
||||
e.SenderId == senderId &&
|
||||
e.RecipientAccountId == request.RecipientId &&
|
||||
e.RecipientDeviceId == null &&
|
||||
e.ClientMessageId == request.ClientMessageId
|
||||
);
|
||||
if (existing is not null)
|
||||
return existing;
|
||||
}
|
||||
|
||||
var recipient = await accountService.GetAccount(request.RecipientId);
|
||||
if (recipient is null)
|
||||
throw new KeyNotFoundException("Recipient not found.");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var nextSequence = await db.E2eeEnvelopes
|
||||
.Where(m => m.RecipientAccountId == request.RecipientId && m.RecipientDeviceId == null)
|
||||
.Select(m => (long?)m.Sequence)
|
||||
.MaxAsync() ?? 0;
|
||||
nextSequence += 1;
|
||||
|
||||
var envelope = new SnE2eeEnvelope
|
||||
{
|
||||
SenderId = senderId,
|
||||
SenderDeviceId = LegacyDeviceId,
|
||||
RecipientId = request.RecipientId,
|
||||
RecipientAccountId = request.RecipientId,
|
||||
RecipientDeviceId = null,
|
||||
SessionId = request.SessionId,
|
||||
Type = request.Type,
|
||||
GroupId = request.GroupId,
|
||||
ClientMessageId = request.ClientMessageId,
|
||||
Sequence = nextSequence,
|
||||
Ciphertext = request.Ciphertext,
|
||||
Header = request.Header,
|
||||
Signature = request.Signature,
|
||||
ExpiresAt = request.ExpiresAt is null ? null : Instant.FromDateTimeOffset(request.ExpiresAt.Value),
|
||||
LegacyAccountScoped = true,
|
||||
Meta = request.Meta,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
db.E2eeEnvelopes.Add(envelope);
|
||||
|
||||
if (request.SessionId.HasValue)
|
||||
{
|
||||
var session = await db.E2eeSessions.FirstOrDefaultAsync(s => s.Id == request.SessionId.Value);
|
||||
if (session is not null)
|
||||
session.LastMessageAt = now;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await TryDeliverEnvelopeAsync(envelope);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
public async Task<List<SnE2eeEnvelope>> SendFanoutEnvelopesAsync(
|
||||
Guid senderId,
|
||||
string senderDeviceId,
|
||||
SendE2eeFanoutRequest request
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(senderDeviceId))
|
||||
throw new InvalidOperationException("senderDeviceId cannot be empty.");
|
||||
if (request.Payloads.Count == 0)
|
||||
throw new InvalidOperationException("payloads cannot be empty.");
|
||||
if (request.Payloads.Count > MaxFanoutPayloadsPerRequest)
|
||||
throw new InvalidOperationException(
|
||||
$"Too many payloads in one fanout request. Max allowed: {MaxFanoutPayloadsPerRequest}.");
|
||||
|
||||
var recipient = await accountService.GetAccount(request.RecipientAccountId);
|
||||
if (recipient is null)
|
||||
throw new KeyNotFoundException("Recipient not found.");
|
||||
|
||||
var activeDevices = await db.E2eeDevices
|
||||
.Where(d => d.AccountId == request.RecipientAccountId && !d.IsRevoked)
|
||||
.Select(d => d.DeviceId)
|
||||
.ToListAsync();
|
||||
if (activeDevices.Count == 0)
|
||||
throw new InvalidOperationException("Recipient has no active E2EE devices.");
|
||||
|
||||
var payloadByDevice = request.Payloads.ToDictionary(p => p.RecipientDeviceId, p => p);
|
||||
var missingDevices = activeDevices.Where(d => !payloadByDevice.ContainsKey(d)).ToList();
|
||||
if (missingDevices.Count > 0)
|
||||
throw new InvalidOperationException($"Missing ciphertext for recipient devices: {string.Join(", ", missingDevices)}");
|
||||
|
||||
var extraDevices = request.Payloads.Select(p => p.RecipientDeviceId).Where(d => !activeDevices.Contains(d)).Distinct()
|
||||
.ToList();
|
||||
if (extraDevices.Count > 0)
|
||||
throw new InvalidOperationException($"Payload includes unknown/revoked devices: {string.Join(", ", extraDevices)}");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var envelopes = new List<SnE2eeEnvelope>();
|
||||
|
||||
foreach (var payload in request.Payloads)
|
||||
{
|
||||
var envelope = await CreateEnvelopeForTargetAsync(
|
||||
senderId,
|
||||
senderDeviceId,
|
||||
request.RecipientAccountId,
|
||||
payload.RecipientDeviceId,
|
||||
request.SessionId,
|
||||
request.Type,
|
||||
request.GroupId,
|
||||
payload.ClientMessageId,
|
||||
payload.Ciphertext,
|
||||
payload.Header,
|
||||
payload.Signature,
|
||||
request.ExpiresAt,
|
||||
payload.Meta,
|
||||
legacyAccountScoped: false,
|
||||
createdAt: now
|
||||
);
|
||||
envelopes.Add(envelope);
|
||||
}
|
||||
|
||||
if (request.IncludeSenderCopy && request.RecipientAccountId != senderId)
|
||||
{
|
||||
var senderPayload = request.Payloads.FirstOrDefault(p => p.RecipientDeviceId == senderDeviceId);
|
||||
if (senderPayload is not null)
|
||||
{
|
||||
var senderCopyMeta = senderPayload.Meta is null
|
||||
? new Dictionary<string, object>()
|
||||
: new Dictionary<string, object>(senderPayload.Meta);
|
||||
senderCopyMeta["sender_copy"] = true;
|
||||
var senderCopy = await CreateEnvelopeForTargetAsync(
|
||||
senderId,
|
||||
senderDeviceId,
|
||||
senderId,
|
||||
senderDeviceId,
|
||||
request.SessionId,
|
||||
request.Type,
|
||||
request.GroupId,
|
||||
senderPayload.ClientMessageId is null ? null : $"{senderPayload.ClientMessageId}:self",
|
||||
senderPayload.Ciphertext,
|
||||
senderPayload.Header,
|
||||
senderPayload.Signature,
|
||||
request.ExpiresAt,
|
||||
senderCopyMeta,
|
||||
legacyAccountScoped: false,
|
||||
createdAt: now
|
||||
);
|
||||
envelopes.Add(senderCopy);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.SessionId.HasValue)
|
||||
{
|
||||
var session = await db.E2eeSessions.FirstOrDefaultAsync(s => s.Id == request.SessionId.Value);
|
||||
if (session is not null)
|
||||
session.LastMessageAt = now;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
foreach (var envelope in envelopes)
|
||||
await TryDeliverEnvelopeAsync(envelope);
|
||||
|
||||
return envelopes;
|
||||
}
|
||||
|
||||
public async Task<List<SnE2eeEnvelope>> GetPendingEnvelopesAsync(Guid recipientId, int take)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var envelopes = await db.E2eeEnvelopes
|
||||
.Where(e => e.RecipientAccountId == recipientId && e.RecipientDeviceId == null)
|
||||
.Where(e => e.DeliveryStatus != SnE2eeEnvelopeStatus.Acknowledged)
|
||||
.Where(e => e.ExpiresAt == null || e.ExpiresAt > now)
|
||||
.OrderBy(e => e.Sequence)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
var dirty = false;
|
||||
foreach (var envelope in envelopes.Where(e => e.DeliveryStatus == SnE2eeEnvelopeStatus.Pending))
|
||||
{
|
||||
envelope.DeliveryStatus = SnE2eeEnvelopeStatus.Delivered;
|
||||
envelope.DeliveredAt = now;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (dirty)
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return envelopes;
|
||||
}
|
||||
|
||||
public async Task<List<SnE2eeEnvelope>> GetPendingEnvelopesByDeviceAsync(Guid recipientId, string deviceId, int take)
|
||||
{
|
||||
var activeDevice = await db.E2eeDevices
|
||||
.Where(d => d.AccountId == recipientId && d.DeviceId == deviceId && !d.IsRevoked)
|
||||
.FirstOrDefaultAsync();
|
||||
if (activeDevice is null)
|
||||
return [];
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var envelopes = await db.E2eeEnvelopes
|
||||
.Where(e => e.RecipientAccountId == recipientId && e.RecipientDeviceId == deviceId)
|
||||
.Where(e => e.DeliveryStatus != SnE2eeEnvelopeStatus.Acknowledged)
|
||||
.Where(e => e.ExpiresAt == null || e.ExpiresAt > now)
|
||||
.OrderBy(e => e.Sequence)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
var dirty = false;
|
||||
foreach (var envelope in envelopes.Where(e => e.DeliveryStatus == SnE2eeEnvelopeStatus.Pending))
|
||||
{
|
||||
envelope.DeliveryStatus = SnE2eeEnvelopeStatus.Delivered;
|
||||
envelope.DeliveredAt = now;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (dirty)
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return envelopes;
|
||||
}
|
||||
|
||||
public async Task<SnE2eeEnvelope?> AcknowledgeEnvelopeAsync(Guid recipientId, Guid envelopeId)
|
||||
{
|
||||
var envelope = await db.E2eeEnvelopes
|
||||
.FirstOrDefaultAsync(e => e.Id == envelopeId && e.RecipientAccountId == recipientId && e.RecipientDeviceId == null);
|
||||
if (envelope is null)
|
||||
return null;
|
||||
|
||||
envelope.DeliveryStatus = SnE2eeEnvelopeStatus.Acknowledged;
|
||||
envelope.AckedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.SaveChangesAsync();
|
||||
return envelope;
|
||||
}
|
||||
|
||||
public async Task<SnE2eeEnvelope?> AcknowledgeEnvelopeByDeviceAsync(Guid recipientId, string deviceId, Guid envelopeId)
|
||||
{
|
||||
var activeDevice = await db.E2eeDevices
|
||||
.Where(d => d.AccountId == recipientId && d.DeviceId == deviceId && !d.IsRevoked)
|
||||
.FirstOrDefaultAsync();
|
||||
if (activeDevice is null)
|
||||
return null;
|
||||
|
||||
var envelope = await db.E2eeEnvelopes
|
||||
.FirstOrDefaultAsync(e => e.Id == envelopeId && e.RecipientAccountId == recipientId && e.RecipientDeviceId == deviceId);
|
||||
if (envelope is null)
|
||||
return null;
|
||||
|
||||
envelope.DeliveryStatus = SnE2eeEnvelopeStatus.Acknowledged;
|
||||
envelope.AckedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.SaveChangesAsync();
|
||||
return envelope;
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeDeviceAsync(Guid accountId, string deviceId)
|
||||
{
|
||||
var device = await db.E2eeDevices
|
||||
.FirstOrDefaultAsync(d => d.AccountId == accountId && d.DeviceId == deviceId);
|
||||
if (device is null)
|
||||
return false;
|
||||
|
||||
if (device.IsRevoked)
|
||||
return true;
|
||||
|
||||
device.IsRevoked = true;
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
device.RevokedAt = now;
|
||||
|
||||
var pending = await db.E2eeEnvelopes
|
||||
.Where(e =>
|
||||
e.RecipientAccountId == accountId &&
|
||||
e.RecipientDeviceId == deviceId &&
|
||||
e.DeliveryStatus != SnE2eeEnvelopeStatus.Acknowledged)
|
||||
.ToListAsync();
|
||||
var purgedCount = pending.Count;
|
||||
if (purgedCount > 0)
|
||||
db.RemoveRange(pending);
|
||||
|
||||
var siblingDevices = await db.E2eeDevices
|
||||
.Where(d => d.AccountId == accountId && !d.IsRevoked && d.DeviceId != deviceId)
|
||||
.Select(d => d.DeviceId)
|
||||
.ToListAsync();
|
||||
var controlEnvelopes = new List<SnE2eeEnvelope>();
|
||||
foreach (var targetDeviceId in siblingDevices)
|
||||
{
|
||||
var controlEnvelope = await CreateEnvelopeForTargetAsync(
|
||||
accountId,
|
||||
LegacyDeviceId,
|
||||
accountId,
|
||||
targetDeviceId,
|
||||
null,
|
||||
SnE2eeEnvelopeType.Control,
|
||||
null,
|
||||
$"mls-revoke-{deviceId}-{now.ToUnixTimeMilliseconds()}-{targetDeviceId}",
|
||||
[1],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["event"] = "mls_device_revoked",
|
||||
["revoked_device_id"] = deviceId
|
||||
},
|
||||
legacyAccountScoped: false,
|
||||
createdAt: now
|
||||
);
|
||||
controlEnvelopes.Add(controlEnvelope);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
foreach (var envelope in controlEnvelopes)
|
||||
await TryDeliverEnvelopeAsync(envelope);
|
||||
logger.LogInformation(
|
||||
"Revoked device {DeviceId} for account {AccountId}. Purged pending envelopes: {PurgedCount}",
|
||||
deviceId, accountId, purgedCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task PurgeExpiredMlsKeyPackagesAsync(Guid accountId, Instant now)
|
||||
{
|
||||
var cutoff = now - Duration.FromDays(MlsKeyPackageRetentionDays);
|
||||
var expired = await db.MlsKeyPackages
|
||||
.Where(k => k.AccountId == accountId && k.CreatedAt < cutoff)
|
||||
.ToListAsync();
|
||||
if (expired.Count == 0)
|
||||
return;
|
||||
|
||||
db.RemoveRange(expired);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<int> DistributeSenderKeyAsync(Guid senderId, DistributeSenderKeyRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.GroupId))
|
||||
throw new InvalidOperationException("GroupId cannot be empty.");
|
||||
if (request.Items.Count == 0)
|
||||
return 0;
|
||||
|
||||
var sent = 0;
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
await SendEnvelopeAsync(senderId, new SendE2eeEnvelopeRequest(
|
||||
item.RecipientId,
|
||||
null,
|
||||
SnE2eeEnvelopeType.SenderKeyDistribution,
|
||||
request.GroupId,
|
||||
item.ClientMessageId,
|
||||
item.Ciphertext,
|
||||
item.Header,
|
||||
item.Signature,
|
||||
request.ExpiresAt,
|
||||
item.Meta
|
||||
));
|
||||
sent++;
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
private async Task TryDeliverEnvelopeAsync(SnE2eeEnvelope envelope)
|
||||
{
|
||||
try
|
||||
{
|
||||
var targetDeviceId = envelope.RecipientDeviceId;
|
||||
var isConnected = targetDeviceId is null
|
||||
? await ws.GetWebsocketConnectionStatus(envelope.RecipientAccountId.ToString(), isUserId: true)
|
||||
: await ws.GetWebsocketConnectionStatus(targetDeviceId, isUserId: false);
|
||||
if (!isConnected)
|
||||
return;
|
||||
|
||||
var payload = InfraObjectCoder.ConvertObjectToByteString(new
|
||||
{
|
||||
envelope.Id,
|
||||
envelope.SenderId,
|
||||
envelope.SenderDeviceId,
|
||||
envelope.RecipientId,
|
||||
envelope.RecipientAccountId,
|
||||
envelope.RecipientDeviceId,
|
||||
envelope.SessionId,
|
||||
envelope.Type,
|
||||
envelope.GroupId,
|
||||
envelope.ClientMessageId,
|
||||
envelope.Sequence,
|
||||
envelope.Ciphertext,
|
||||
envelope.Header,
|
||||
envelope.Signature,
|
||||
envelope.Meta,
|
||||
envelope.LegacyAccountScoped,
|
||||
envelope.CreatedAt
|
||||
}).ToByteArray();
|
||||
if (targetDeviceId is null)
|
||||
await ws.PushWebSocketPacket(envelope.RecipientAccountId.ToString(), PacketType, payload);
|
||||
else
|
||||
await ws.PushWebSocketPacketToDevice(targetDeviceId, PacketType, payload);
|
||||
|
||||
envelope.DeliveryStatus = SnE2eeEnvelopeStatus.Delivered;
|
||||
envelope.DeliveredAt = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex,
|
||||
"Failed to push realtime E2EE envelope {EnvelopeId} to recipient {RecipientId}/{RecipientDeviceId}",
|
||||
envelope.Id, envelope.RecipientAccountId, envelope.RecipientDeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SnE2eeEnvelope> CreateEnvelopeForTargetAsync(
|
||||
Guid senderId,
|
||||
string senderDeviceId,
|
||||
Guid recipientAccountId,
|
||||
string recipientDeviceId,
|
||||
Guid? sessionId,
|
||||
SnE2eeEnvelopeType type,
|
||||
string? groupId,
|
||||
string? clientMessageId,
|
||||
byte[] ciphertext,
|
||||
byte[]? header,
|
||||
byte[]? signature,
|
||||
DateTimeOffset? expiresAt,
|
||||
Dictionary<string, object>? meta,
|
||||
bool legacyAccountScoped,
|
||||
Instant createdAt
|
||||
)
|
||||
{
|
||||
if (ciphertext.Length == 0)
|
||||
throw new InvalidOperationException("Ciphertext cannot be empty.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(clientMessageId))
|
||||
{
|
||||
var existing = await db.E2eeEnvelopes.FirstOrDefaultAsync(e =>
|
||||
e.SenderId == senderId &&
|
||||
e.SenderDeviceId == senderDeviceId &&
|
||||
e.RecipientAccountId == recipientAccountId &&
|
||||
e.RecipientDeviceId == recipientDeviceId &&
|
||||
e.ClientMessageId == clientMessageId
|
||||
);
|
||||
if (existing is not null)
|
||||
return existing;
|
||||
}
|
||||
|
||||
var nextSequence = await db.E2eeEnvelopes
|
||||
.Where(m => m.RecipientAccountId == recipientAccountId && m.RecipientDeviceId == recipientDeviceId)
|
||||
.Select(m => (long?)m.Sequence)
|
||||
.MaxAsync() ?? 0;
|
||||
nextSequence += 1;
|
||||
|
||||
var envelope = new SnE2eeEnvelope
|
||||
{
|
||||
SenderId = senderId,
|
||||
SenderDeviceId = senderDeviceId,
|
||||
RecipientId = recipientAccountId,
|
||||
RecipientAccountId = recipientAccountId,
|
||||
RecipientDeviceId = recipientDeviceId,
|
||||
SessionId = sessionId,
|
||||
Type = type,
|
||||
GroupId = groupId,
|
||||
ClientMessageId = clientMessageId,
|
||||
Sequence = nextSequence,
|
||||
Ciphertext = ciphertext,
|
||||
Header = header,
|
||||
Signature = signature,
|
||||
ExpiresAt = expiresAt is null ? null : Instant.FromDateTimeOffset(expiresAt.Value),
|
||||
Meta = meta,
|
||||
LegacyAccountScoped = legacyAccountScoped,
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = createdAt
|
||||
};
|
||||
db.E2eeEnvelopes.Add(envelope);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private static void EnsurePairOrder(Guid left, Guid right, out Guid accountAId, out Guid accountBId)
|
||||
{
|
||||
if (left.CompareTo(right) <= 0)
|
||||
{
|
||||
accountAId = left;
|
||||
accountBId = right;
|
||||
}
|
||||
else
|
||||
{
|
||||
accountAId = right;
|
||||
accountBId = left;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
|
||||
namespace DysonNetwork.Pass.E2EE;
|
||||
|
||||
public interface IE2eeModule
|
||||
{
|
||||
Task<SnE2eeKeyBundle> UpsertKeyBundleAsync(Guid accountId, UpsertE2eeKeyBundleRequest request);
|
||||
Task<E2eePublicKeyBundleResponse?> GetPublicBundleAsync(Guid accountId, Guid requesterId, bool consumeOneTimePreKey);
|
||||
Task<SnE2eeSession> EnsureSessionAsync(Guid accountId, Guid peerId, EnsureE2eeSessionRequest request);
|
||||
Task<SnE2eeEnvelope> SendEnvelopeAsync(Guid senderId, SendE2eeEnvelopeRequest request);
|
||||
Task<List<SnE2eeEnvelope>> GetPendingEnvelopesAsync(Guid recipientId, int take);
|
||||
Task<SnE2eeEnvelope?> AcknowledgeEnvelopeAsync(Guid recipientId, Guid envelopeId);
|
||||
Task<SnE2eeKeyBundle> UpsertDeviceBundleAsync(
|
||||
Guid accountId,
|
||||
string deviceId,
|
||||
string? deviceLabel,
|
||||
UpsertE2eeKeyBundleRequest request
|
||||
);
|
||||
Task<List<E2eeDevicePublicBundleResponse>> GetPublicDeviceBundlesAsync(
|
||||
Guid accountId,
|
||||
Guid requesterId,
|
||||
bool consumeOneTimePreKey
|
||||
);
|
||||
Task<List<SnE2eeEnvelope>> SendFanoutEnvelopesAsync(
|
||||
Guid senderId,
|
||||
string senderDeviceId,
|
||||
SendE2eeFanoutRequest request
|
||||
);
|
||||
Task<List<SnE2eeEnvelope>> GetPendingEnvelopesByDeviceAsync(Guid recipientId, string deviceId, int take);
|
||||
Task<SnE2eeEnvelope?> AcknowledgeEnvelopeByDeviceAsync(Guid recipientId, string deviceId, Guid envelopeId);
|
||||
Task<bool> RevokeDeviceAsync(Guid accountId, string deviceId);
|
||||
Task<SnMlsKeyPackage> PublishMlsKeyPackageAsync(
|
||||
Guid accountId,
|
||||
string deviceId,
|
||||
string? deviceLabel,
|
||||
PublishMlsKeyPackageRequest request
|
||||
);
|
||||
Task<List<MlsDeviceKeyPackageResponse>> ListMlsDeviceKeyPackagesAsync(Guid accountId, Guid requesterId, bool consume);
|
||||
Task<SnMlsGroupState> BootstrapMlsGroupAsync(Guid accountId, BootstrapMlsGroupRequest request);
|
||||
Task<SnMlsGroupState?> CommitMlsGroupAsync(Guid accountId, CommitMlsGroupRequest request);
|
||||
Task<List<SnE2eeEnvelope>> FanoutMlsWelcomeAsync(Guid senderId, string senderDeviceId, FanoutMlsWelcomeRequest request);
|
||||
Task<SnMlsDeviceMembership> MarkMlsReshareRequiredAsync(Guid accountId, MarkMlsReshareRequiredRequest request);
|
||||
}
|
||||
|
||||
public interface IGroupE2eeModule : IE2eeModule
|
||||
{
|
||||
Task<int> DistributeSenderKeyAsync(Guid senderId, DistributeSenderKeyRequest request);
|
||||
}
|
||||
|
||||
public record UpsertE2eeOneTimePreKey(int KeyId, byte[] PublicKey);
|
||||
|
||||
public record UpsertE2eeKeyBundleRequest(
|
||||
string Algorithm,
|
||||
byte[] IdentityKey,
|
||||
int? SignedPreKeyId,
|
||||
byte[] SignedPreKey,
|
||||
byte[] SignedPreKeySignature,
|
||||
DateTimeOffset? SignedPreKeyExpiresAt,
|
||||
List<UpsertE2eeOneTimePreKey>? OneTimePreKeys,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record EnsureE2eeSessionRequest(
|
||||
string? Hint,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record SendE2eeEnvelopeRequest(
|
||||
Guid RecipientId,
|
||||
Guid? SessionId,
|
||||
SnE2eeEnvelopeType Type,
|
||||
string? GroupId,
|
||||
string? ClientMessageId,
|
||||
byte[] Ciphertext,
|
||||
byte[]? Header,
|
||||
byte[]? Signature,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record SenderKeyDistributionItem(
|
||||
Guid RecipientId,
|
||||
byte[] Ciphertext,
|
||||
byte[]? Header,
|
||||
byte[]? Signature,
|
||||
string? ClientMessageId,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record DistributeSenderKeyRequest(
|
||||
string GroupId,
|
||||
List<SenderKeyDistributionItem> Items,
|
||||
DateTimeOffset? ExpiresAt
|
||||
);
|
||||
|
||||
public record E2eePublicKeyBundleResponse(
|
||||
Guid AccountId,
|
||||
string Algorithm,
|
||||
byte[] IdentityKey,
|
||||
int? SignedPreKeyId,
|
||||
byte[] SignedPreKey,
|
||||
byte[] SignedPreKeySignature,
|
||||
DateTimeOffset? SignedPreKeyExpiresAt,
|
||||
UpsertE2eeOneTimePreKey? OneTimePreKey,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record E2eeDevicePublicBundleResponse(
|
||||
Guid AccountId,
|
||||
string DeviceId,
|
||||
string? DeviceLabel,
|
||||
string Algorithm,
|
||||
byte[] IdentityKey,
|
||||
int? SignedPreKeyId,
|
||||
byte[] SignedPreKey,
|
||||
byte[] SignedPreKeySignature,
|
||||
DateTimeOffset? SignedPreKeyExpiresAt,
|
||||
UpsertE2eeOneTimePreKey? OneTimePreKey,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record DeviceCiphertextEnvelope(
|
||||
string RecipientDeviceId,
|
||||
string? ClientMessageId,
|
||||
byte[] Ciphertext,
|
||||
byte[]? Header,
|
||||
byte[]? Signature,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record SendE2eeFanoutRequest(
|
||||
Guid RecipientAccountId,
|
||||
Guid? SessionId,
|
||||
SnE2eeEnvelopeType Type,
|
||||
string? GroupId,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
bool IncludeSenderCopy,
|
||||
List<DeviceCiphertextEnvelope> Payloads
|
||||
);
|
||||
|
||||
public record PublishMlsKeyPackageRequest(
|
||||
byte[] KeyPackage,
|
||||
string Ciphersuite,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record MlsDeviceKeyPackageResponse(
|
||||
Guid AccountId,
|
||||
string DeviceId,
|
||||
string? DeviceLabel,
|
||||
string Ciphersuite,
|
||||
byte[] KeyPackage,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record BootstrapMlsGroupRequest(
|
||||
Guid ChatRoomId,
|
||||
string MlsGroupId,
|
||||
long Epoch,
|
||||
long StateVersion,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record CommitMlsGroupRequest(
|
||||
Guid ChatRoomId,
|
||||
string MlsGroupId,
|
||||
long Epoch,
|
||||
string Reason,
|
||||
Dictionary<string, object>? Meta
|
||||
);
|
||||
|
||||
public record FanoutMlsWelcomeRequest(
|
||||
Guid ChatRoomId,
|
||||
string MlsGroupId,
|
||||
Guid RecipientAccountId,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
List<DeviceCiphertextEnvelope> Payloads
|
||||
);
|
||||
|
||||
public record MarkMlsReshareRequiredRequest(
|
||||
Guid ChatRoomId,
|
||||
string MlsGroupId,
|
||||
Guid TargetAccountId,
|
||||
string TargetDeviceId,
|
||||
long Epoch,
|
||||
string Reason
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Pass.Startup;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Networking;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -13,10 +14,10 @@ builder.ConfigureAppKestrel(builder.Configuration);
|
||||
|
||||
// Add application services
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddDysonAuth();
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddBladeService();
|
||||
builder.Services.AddRingService();
|
||||
builder.Services.AddAuthService();
|
||||
builder.Services.AddDriveService();
|
||||
builder.Services.AddDevelopService();
|
||||
builder.Services.AddWalletService();
|
||||
|
||||
@@ -14,7 +14,6 @@ using DysonNetwork.Pass.Affiliation;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||
using DysonNetwork.Pass.Credit;
|
||||
using DysonNetwork.Pass.E2EE;
|
||||
using DysonNetwork.Pass.Handlers;
|
||||
using DysonNetwork.Pass.Leveling;
|
||||
using DysonNetwork.Pass.Mailer;
|
||||
@@ -117,14 +116,6 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthorization();
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = DysonNetwork.Shared.Auth.AuthConstants.SchemeName;
|
||||
options.DefaultChallengeScheme = DysonNetwork.Shared.Auth.AuthConstants.SchemeName;
|
||||
})
|
||||
.AddScheme<DysonNetwork.Shared.Auth.DysonTokenAuthOptions, DysonNetwork.Shared.Auth.DysonTokenAuthHandler>(
|
||||
DysonNetwork.Shared.Auth.AuthConstants.SchemeName,
|
||||
_ => { });
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -186,10 +177,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<PassRewindService>();
|
||||
services.AddScoped<AccountRewindService>();
|
||||
services.AddScoped<TicketService>();
|
||||
services.AddScoped<E2EeService>();
|
||||
services.AddScoped<IE2eeModule>(sp => sp.GetRequiredService<E2EeService>());
|
||||
services.AddScoped<IGroupE2eeModule>(sp => sp.GetRequiredService<E2EeService>());
|
||||
|
||||
services.AddEventBus()
|
||||
.AddListener<WebSocketConnectedEvent>(async (evt, ctx) =>
|
||||
{
|
||||
|
||||
@@ -44,7 +44,16 @@ public class DysonTokenAuthHandler(
|
||||
{
|
||||
var tokenInfo = ExtractToken(Request, config);
|
||||
if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token))
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"Auth failed: no token extracted. path={Path} authHeaderPresent={AuthPresent} xForwardedAuthPresent={FwdPresent} xOriginalAuthPresent={OrigPresent}",
|
||||
Request.Path,
|
||||
Request.Headers.ContainsKey("Authorization"),
|
||||
Request.Headers.ContainsKey("X-Forwarded-Authorization"),
|
||||
Request.Headers.ContainsKey("X-Original-Authorization")
|
||||
);
|
||||
return AuthenticateResult.Fail("No token was provided.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -79,7 +88,11 @@ public class DysonTokenAuthHandler(
|
||||
}
|
||||
|
||||
if (!ShouldUseLegacyFallback(config))
|
||||
{
|
||||
Logger.LogWarning("Auth failed: local JWT validation failed and legacy fallback disabled. path={Path} reason={Reason}",
|
||||
Request.Path, failMessage ?? "unknown");
|
||||
return AuthenticateResult.Fail(failMessage ?? "Token validation failed.");
|
||||
}
|
||||
|
||||
// Compatibility path for old compact/legacy tokens during grace window.
|
||||
DyAuthSession legacySession;
|
||||
@@ -89,10 +102,13 @@ public class DysonTokenAuthHandler(
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Logger.LogWarning("Auth failed via gRPC fallback. path={Path} reason={Reason}", Request.Path, ex.Message);
|
||||
return AuthenticateResult.Fail(ex.Message);
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
Logger.LogWarning("Auth failed via gRPC fallback rpc. path={Path} code={Code} detail={Detail}",
|
||||
Request.Path, ex.Status.StatusCode, ex.Status.Detail);
|
||||
return AuthenticateResult.Fail($"Remote error: {ex.Status.StatusCode} - {ex.Status.Detail}");
|
||||
}
|
||||
|
||||
@@ -100,6 +116,7 @@ public class DysonTokenAuthHandler(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Authentication failed unexpectedly. path={Path}", Request.Path);
|
||||
return AuthenticateResult.Fail($"Authentication failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -242,7 +259,7 @@ public class DysonTokenAuthHandler(
|
||||
return new TokenInfo { Token = queryToken.ToString(), Type = TokenType.AuthKey };
|
||||
}
|
||||
|
||||
var authHeader = request.Headers.Authorization.ToString();
|
||||
var authHeader = NormalizeAuthHeader(ExtractRawAuthHeader(request));
|
||||
if (!string.IsNullOrEmpty(authHeader))
|
||||
{
|
||||
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -264,4 +281,35 @@ public class DysonTokenAuthHandler(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ExtractRawAuthHeader(HttpRequest request)
|
||||
{
|
||||
// Standard header first.
|
||||
var authHeader = request.Headers.Authorization.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(authHeader)) return authHeader;
|
||||
|
||||
// Common proxy-forwarded header variants.
|
||||
authHeader = request.Headers["X-Forwarded-Authorization"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(authHeader)) return authHeader;
|
||||
|
||||
authHeader = request.Headers["X-Original-Authorization"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(authHeader)) return authHeader;
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizeAuthHeader(string raw)
|
||||
{
|
||||
var value = raw?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
|
||||
// Some clients/proxies serialize header lists as "[Bearer xxx]".
|
||||
if (value.Length >= 2 && value[0] == '[' && value[^1] == ']')
|
||||
value = value[1..^1].Trim();
|
||||
|
||||
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
|
||||
value = value[1..^1].Trim();
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArraySegment_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe6898c1ddf974e16b95b114722270029e55000_003F44_003Fb46e5e1b_003FArraySegment_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAspireNatsClientExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F991136f2b57436db7b12f422388f1384993f9288637d2c461e0e9da6b55a88b_003FAspireNatsClientExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationHandler_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1f1354e4dbf943ecb04840af5ff9a527fa20_003F5d_003F1fb111f6_003FAuthenticationHandler_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationHandler_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb0bec8aedaeb46bfa6542c8c6834fb4026938_003Fbd_003F4a1d2854_003FAuthenticationHandler_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe49de78932194d52a02b07486c6d023a24600_003F2f_003F7ab1cc57_003FAuthenticationMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationSchemeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F9b24a56e61ae4d86a9e8ba13482a2db924600_003F5b_003F9e854504_003FAuthenticationSchemeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationSchemeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe49de78932194d52a02b07486c6d023a24600_003Ff0_003F595b6eda_003FAuthenticationSchemeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
|
||||
Reference in New Issue
Block a user