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