using DodoSSH.Contracts; using DodoSSH.Domain; using DodoSSH.Infrastructure; using Microsoft.EntityFrameworkCore; namespace DodoSSH.Api.Features.Identity; /// /// The public-key directory. /// /// /// /// This is where a client gets the key it is about to wrap a vault key to, so its shape is a security /// decision rather than a convenience one. Two rules follow from that. /// /// /// Lookup by exact email, never by prefix. There is no search, no wildcard and no listing of /// everybody. A caller has to already know the address, which keeps this from being a way to /// enumerate an organisation's staff out of a server that stores their addresses in plaintext. /// /// /// Lookup by id is restricted to people the caller shares a team with. Ids come from a member /// list the caller can already read, so nothing is hidden that they cannot reach another way — but an /// unrestricted id lookup would turn a leaked id from any source into a directory hit. /// /// /// What this returns is evidence, not authority. A client must check the identity-provider /// binding, compare against any fingerprint it has pinned, and confirm the key log head before /// wrapping anything. Trusting the directory's word is the one mistake that undoes end-to-end /// encryption entirely; see ADR 0001 and DirectoryEntry's own remarks. /// /// internal sealed class DirectoryService(DodoDbContext database) { /// Looks a user up by exact email address. internal async Task> FindByEmailAsync( string email, CancellationToken cancellationToken) { var normalised = email.Trim(); if (normalised.Length == 0) { return []; } // The email column is citext, so this comparison is case-insensitive in the database and the // partial unique index on it means at most one row can match. Written as a list anyway // because the response shape must not have to change if a second issuer ever shares one. var users = await database.Users .Where(u => u.Email == normalised && u.DeletedAtUtc == null && u.Status == UserStatus.Active) .ToListAsync(cancellationToken) .ConfigureAwait(false); return await BuildAsync(users, cancellationToken).ConfigureAwait(false); } /// Looks up accounts the caller shares an active team with. internal async Task> FindTeammatesAsync( Guid callerId, IReadOnlyList userIds, CancellationToken cancellationToken) { if (userIds.Count == 0) { return []; } var teamIds = await database.TeamMemberships .Where(m => m.UserId == callerId && m.Status == MembershipStatus.Active && m.DeletedAtUtc == null) .Select(m => m.TeamId) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (teamIds.Count == 0) { return []; } var visible = await database.TeamMemberships .Where(m => teamIds.Contains(m.TeamId) && userIds.Contains(m.UserId) && m.Status == MembershipStatus.Active && m.DeletedAtUtc == null) .Select(m => m.UserId) .Distinct() .ToListAsync(cancellationToken) .ConfigureAwait(false); var users = await database.Users .Where(u => visible.Contains(u.Id) && u.DeletedAtUtc == null) .ToListAsync(cancellationToken) .ConfigureAwait(false); return await BuildAsync(users, cancellationToken).ConfigureAwait(false); } /// /// A user with no current key is dropped rather than returned with empty key fields. The entry /// exists to be wrapped to, and one carrying no key is something a caller would have to remember /// to check for — which is the kind of check that gets forgotten exactly once. /// private async Task> BuildAsync( List users, CancellationToken cancellationToken) { if (users.Count == 0) { return []; } var userIds = users.Select(u => u.Id).ToArray(); var keys = await database.UserKeys .Where(k => userIds.Contains(k.UserId) && k.IsCurrent) .ToListAsync(cancellationToken) .ConfigureAwait(false); // The log position of the statement that introduced each key, so a client can compare what // it is told here against the append-only chain rather than taking this response on trust. var keyIds = keys.Select(k => k.UserId).ToArray(); var sequences = await database.KeyLog .Where(e => keyIds.Contains(e.UserId)) .GroupBy(e => new { e.UserId, e.Generation }) .Select(g => new { g.Key.UserId, g.Key.Generation, Sequence = g.Min(e => e.Sequence) }) .ToListAsync(cancellationToken) .ConfigureAwait(false); var entries = new List(users.Count); foreach (var user in users) { var key = keys.Find(k => k.UserId == user.Id); if (key is null) { continue; } var sequence = sequences .Find(s => s.UserId == user.Id && s.Generation == key.Generation)? .Sequence ?? 0; entries.Add(new DirectoryEntry( UserId: user.Id, Email: user.Email, DisplayName: user.DisplayName, EncryptionPublicKey: key.EncryptionPublicKey, SigningPublicKey: key.SigningPublicKey, Fingerprint: key.FingerprintSha256, KeyGeneration: key.Generation, KeyLogSequence: sequence)); } return entries; } }