using System.Security.Cryptography; using System.Text.Json; using DodoSSH.Crypto; using Microsoft.EntityFrameworkCore; namespace DodoSSH.Client.Storage; /// This device's sign-in to one Spelunk, as keeps it. /// The Spelunk API's base address, normalised as a host's route stores it. /// What Spelunk issued this device: sent as the bearer token on every ticket request. /// Spelunk's id for this sign-in, which its person sees under Your account. /// Who it is signed in as, for saying so. /// The same, as Spelunk names them. /// When this device signed in. public sealed record SpelunkSignInRecord( string ApiBase, string Token, Guid SessionId, string Email, string DisplayName, DateTimeOffset SignedInAtUtc) { /// Keeps the token out of logs and debugger views. public override string ToString() => $"{Email} at {ApiBase}"; } /// /// The Spelunks this device is signed in to, one sign-in each, sealed under the LocalCacheKey — see /// for why per device and why sealed whole. /// public sealed class SpelunkSignInStore( IDbContextFactory contexts, LocalCacheProtector protector, TimeProvider clock) { /// Every sign-in this key can open. One that will not open is skipped, as a stale cache record is. public async Task> ListAsync(CancellationToken cancellationToken) { var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var rows = await context.Set() .AsNoTracking() .ToListAsync(cancellationToken) .ConfigureAwait(false); return rows .Select(Open) .OfType() .OrderBy(record => record.ApiBase, StringComparer.Ordinal) .ToList(); } /// The sign-in for a Spelunk, or null when this device is not signed in to it. public async Task FindAsync(string apiBase, CancellationToken cancellationToken) => (await ListAsync(cancellationToken).ConfigureAwait(false)) .FirstOrDefault(record => string.Equals(record.ApiBase, apiBase, StringComparison.Ordinal)); /// Keeps a sign-in, replacing any this device had for the same Spelunk. public async Task SaveAsync(SpelunkSignInRecord record, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(record); var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var rows = await context.Set().ToListAsync(cancellationToken).ConfigureAwait(false); context.RemoveRange(rows.Where(row => Open(row) is not { } open || string.Equals(open.ApiBase, record.ApiBase, StringComparison.Ordinal))); var id = Guid.CreateVersion7(); var bytes = Serialize(record); try { context.Add(new SpelunkSignInRow { Id = id, Sealed = protector.Protect(CryptoSpec.AadResourceType.SpelunkSignIn, id, bytes), UpdatedAtUtc = clock.GetUtcNow(), }); } finally { CryptographicOperations.ZeroMemory(bytes); } await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } /// Forgets the sign-in for a Spelunk, if this device had one. public async Task ForgetAsync(string apiBase, CancellationToken cancellationToken) { var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var rows = await context.Set().ToListAsync(cancellationToken).ConfigureAwait(false); context.RemoveRange(rows.Where(row => Open(row) is not { } open || string.Equals(open.ApiBase, apiBase, StringComparison.Ordinal))); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } /// Forgets every Spelunk sign-in on this device. public async Task ForgetAllAsync(CancellationToken cancellationToken) { var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); await context.Set().ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); } private SpelunkSignInRecord? Open(SpelunkSignInRow row) { var opened = protector.TryUnprotect(CryptoSpec.AadResourceType.SpelunkSignIn, row.Id, row.Sealed); if (opened is null) { return null; } try { return Deserialize(opened); } finally { CryptographicOperations.ZeroMemory(opened); } } // Written by hand rather than through a serializer context: six fields, and nothing here should depend // on reflection the Android build trims away. private static byte[] Serialize(SpelunkSignInRecord record) { using var buffer = new MemoryStream(); using (var writer = new Utf8JsonWriter(buffer)) { writer.WriteStartObject(); writer.WriteString("api", record.ApiBase); writer.WriteString("token", record.Token); writer.WriteString("session", record.SessionId); writer.WriteString("email", record.Email); writer.WriteString("name", record.DisplayName); writer.WriteString("at", record.SignedInAtUtc); writer.WriteEndObject(); } return buffer.ToArray(); } private static SpelunkSignInRecord? Deserialize(byte[] bytes) { try { using var document = JsonDocument.Parse(bytes); var root = document.RootElement; return new SpelunkSignInRecord( root.GetProperty("api").GetString()!, root.GetProperty("token").GetString()!, root.GetProperty("session").GetGuid(), root.GetProperty("email").GetString()!, root.GetProperty("name").GetString()!, root.GetProperty("at").GetDateTimeOffset()); } catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException or FormatException) { return null; } } }