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