using DodoSSH.Contracts; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace DodoSSH.Client.Storage; /// /// Stores a timestamp as Unix milliseconds. /// /// /// /// Not a preference. SQLite has no date type, and EF's default mapping for /// is a text form that it then refuses to order or compare — any /// query with ORDER BY or a range filter on such a column throws /// at execution time, not at model build. Collecting tombstones /// older than a cutoff and listing conflicts newest-first are both exactly that shape, so this was a /// crash waiting for the first user with a deleted host. Found by the tests that do both. /// /// /// An integer also sorts and compares correctly by construction, which the text form does not once /// two rows carry different UTC offsets. The cost is losing sub-millisecond precision and normalising /// to UTC — neither of which matters here, and both of which docs/crypto.md §7 already does to every /// timestamp it signs over. /// /// internal sealed class UnixMillisecondsConverter : ValueConverter { /// Public because EF instantiates this reflectively and needs a public constructor. public UnixMillisecondsConverter() : base( value => value.ToUnixTimeMilliseconds(), value => DateTimeOffset.FromUnixTimeMilliseconds(value)) { } } /// /// The local cache database. /// /// /// /// Public only because the migrations tooling needs to reach it. The row types stay internal and /// there are no properties: callers go through the stores, which is what /// keeps the sealing of protected columns from being something a call site can forget. Entities are /// registered explicitly in and reached with /// . /// /// /// Migrations rather than EnsureCreated, even for a cache. The item rows are indeed disposable /// — worst case they re-pull from a null cursor — but is not: dropping /// it would mean a user who upgrades while offline cannot open their vault until they are back on the /// network, which is exactly the situation the offline unlock exists for. /// /// public sealed class ClientCacheContext(DbContextOptions options) : DbContext(options) { /// /// /// Applied as a convention rather than per property, so a timestamp added later cannot be the one /// that is left un-converted — which would fail only when something eventually sorted by it. /// protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { ArgumentNullException.ThrowIfNull(configurationBuilder); configurationBuilder.Properties().HaveConversion(); } /// protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); ConfigureUnlockMaterial(modelBuilder); ConfigureRememberedSignIn(modelBuilder); ConfigureVaults(modelBuilder); ConfigureItems(modelBuilder); ConfigureOutbox(modelBuilder); ConfigureSyncState(modelBuilder); ConfigureConflicts(modelBuilder); } private static void ConfigureUnlockMaterial(ModelBuilder modelBuilder) => modelBuilder.Entity(entity => { entity.ToTable( "unlock_material", // One server and one user per cache file. The constraint is here rather than only in // code so that a second row cannot appear through any path at all — including a // future migration written by someone who has not read this comment. table => table.HasCheckConstraint( "ck_unlock_material_singleton", $"id = {UnlockMaterialRow.SingletonId}")); entity.HasKey(row => row.Id); entity.Property(row => row.Id).ValueGeneratedNever(); entity.Property(row => row.ServerUrl).IsRequired(); entity.Property(row => row.Issuer).IsRequired(); entity.Property(row => row.Subject).IsRequired(); entity.Property(row => row.WrappedPrivateKey).IsRequired(); entity.Property(row => row.KdfAlgorithm).IsRequired(); entity.Property(row => row.KdfSalt).IsRequired(); }); /// /// A table of its own rather than two more columns on unlock_material, because the two rows have /// opposite lifetimes: the unlock material is what makes this machine work offline and must survive /// everything short of a reset, while a remembered sign-in is dropped the moment the server stops /// accepting it. Deleting one must never be able to take the other with it. /// private static void ConfigureRememberedSignIn(ModelBuilder modelBuilder) => modelBuilder.Entity(entity => { entity.ToTable( "remembered_sign_in", table => table.HasCheckConstraint( "ck_remembered_sign_in_singleton", $"id = {RememberedSignInRow.SingletonId}")); entity.HasKey(row => row.Id); entity.Property(row => row.Id).ValueGeneratedNever(); entity.Property(row => row.SealedRefreshToken).IsRequired(); }); private static void ConfigureVaults(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => { entity.ToTable("vault"); entity.HasKey(row => row.VaultId); entity.Property(row => row.VaultId).ValueGeneratedNever(); entity.Property(row => row.Name).IsRequired(); }); // No foreign key to the vault row, deliberately. The two are written by the same store in the // same call, and a cascade would make "which of these two tables is authoritative" a question // the schema answers rather than the code — while buying nothing, since a wrap for a vault this // machine can no longer see is removed by the same pass that removes the vault. modelBuilder.Entity(entity => { entity.ToTable("vault_key_wrap"); entity.HasKey(row => new { row.VaultId, row.KeyGeneration }); entity.Property(row => row.WrappedKey).IsRequired(); }); } private static void ConfigureItems(ModelBuilder modelBuilder) => modelBuilder.Entity(entity => { entity.ToTable("item"); // Composite rather than the entity id alone. Ids are UUIDv7 and globally unique in // practice, but making the vault part of the identity means a row can never be read out // of the wrong vault by a query that forgot to filter. entity.HasKey(row => new { row.VaultId, row.EntityType, row.EntityId }); entity.HasIndex(row => new { row.VaultId, row.EntityType }); entity.HasIndex(row => new { row.VaultId, row.ChangeSequence }); }); private static void ConfigureOutbox(ModelBuilder modelBuilder) => modelBuilder.Entity(entity => { entity.ToTable("outbox"); entity.HasKey(row => row.Sequence); entity.Property(row => row.Sequence).ValueGeneratedOnAdd(); // At most one pending operation per item, enforced by the database rather than by // convention. Two queued edits to one item would have to be pushed in order, and the // second would need the version the first produced — which is not known when it is // queued. Coalescing into this single row avoids the problem instead of managing it. entity.HasIndex(row => new { row.VaultId, row.EntityType, row.EntityId }).IsUnique(); // The drain order. entity.HasIndex(row => new { row.VaultId, row.IsParked, row.Sequence }); entity.HasIndex(row => row.OperationId).IsUnique(); }); private static void ConfigureSyncState(ModelBuilder modelBuilder) => modelBuilder.Entity(entity => { entity.ToTable("sync_state"); entity.HasKey(row => row.VaultId); entity.Property(row => row.VaultId).ValueGeneratedNever(); }); private static void ConfigureConflicts(ModelBuilder modelBuilder) => modelBuilder.Entity(entity => { entity.ToTable("conflict"); entity.HasKey(row => row.Id); entity.Property(row => row.Id).ValueGeneratedNever(); entity.Property(row => row.Detail).IsRequired(); entity.HasIndex(row => new { row.VaultId, row.Acknowledged }); entity.HasIndex(row => new { row.VaultId, row.EntityType, row.EntityId }); }); }