🐛 Fix something

This commit is contained in:
2026-03-07 02:07:42 +08:00
parent 20fa8792d0
commit 894aa4d0bd
12 changed files with 69 additions and 1818 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using Microsoft.EntityFrameworkCore;

View File

@@ -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;

View File

@@ -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 };
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
);

View File

@@ -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();

View File

@@ -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) =>
{

View File

@@ -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;
}
}

View File

@@ -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>