Public Access
Adding somebody to a team granted them nothing readable and removing them
rotated nothing. Both were honest — the interface said so in as many words — and
both left the actual work to a button somebody had to remember to press, on a
machine that happened to hold the key. Adding now wraps every team vault this
machine can open to the new member, and removing revokes their grants and moves
each of those vaults to a fresh key that goes to whoever is left.
The rotation is where the design had to be decided rather than written. A vault
key is per generation and an item carries the generation it was sealed under, so
advancing the vault and withdrawing the old grants would make everything already
stored unreadable to everybody, including whoever pressed the button. So earlier
grants are kept: a member holds one per generation, /me serves them as
PriorKeyWraps, and VaultKeyring holds a key per generation — the newest for
writing, the item's own for reading, chosen per item on every read path. Sharing
issues one grant per generation held, because a recipient handed only the current
key would open the vault to find most of it undecryptable; revocation takes every
generation, because leaving the history behind leaves them able to read
everything written before the rotation.
The bump itself is one server transaction. POST /vaults/{id}/rekey must name
exactly current + 1 and the vault's xmin token makes that binding, so two admins
rotating at once do not both walk away believing they succeeded — the second is
refused and told to read the vault again. The server contributes the moment and
no cryptography: it cannot generate the key, cannot tell that the one it is
handed differs from the old one, and checks that the caller held the old one the
only way it can, by requiring a live grant at the current generation.
What this does not do is re-encrypt what is already stored, and the product says
so rather than the reassuring version: everything written from the rotation
onwards is unreadable to the person who left, and nothing about the past changes.
That half is deferred and is safe to add incrementally precisely because a vault
at mixed generations stays readable. ADR 0010 records the alternatives — revoking
the old grants, chaining each key under its successor, re-sealing every item in
one request against a server that caps a push at 500 operations — and why each
was rejected.
Two things fell out of the change rather than being asked for. The grant listing
would have shown a member once per generation, so it now returns one row per
holder carrying the best key they hold, which is what makes a row below the
vault's generation mean "still owed the new key". And MarkUnreadable gives up the
write target as well as reporting: a client whose vault was rotated elsewhere
would otherwise have gone on sealing items under its superseded key — readable to
its author, unreadable to everybody else, with nothing to show for it.
200 lines
9.1 KiB
C#
200 lines
9.1 KiB
C#
using DodoSSH.Contracts;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
|
|
namespace DodoSSH.Client.Storage;
|
|
|
|
/// <summary>
|
|
/// Stores a timestamp as Unix milliseconds.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Not a preference. SQLite has no date type, and EF's default mapping for
|
|
/// <see cref="DateTimeOffset"/> is a text form that it then <b>refuses to order or compare</b> — any
|
|
/// query with <c>ORDER BY</c> or a range filter on such a column throws
|
|
/// <see cref="NotSupportedException"/> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class UnixMillisecondsConverter : ValueConverter<DateTimeOffset, long>
|
|
{
|
|
/// <remarks>Public because EF instantiates this reflectively and needs a public constructor.</remarks>
|
|
public UnixMillisecondsConverter()
|
|
: base(
|
|
value => value.ToUnixTimeMilliseconds(),
|
|
value => DateTimeOffset.FromUnixTimeMilliseconds(value))
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The local cache database.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Public only because the migrations tooling needs to reach it. The row types stay internal and
|
|
/// there are no <see cref="DbSet{TEntity}"/> 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 <see cref="OnModelCreating"/> and reached with
|
|
/// <see cref="DbContext.Set{TEntity}()"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Migrations rather than <c>EnsureCreated</c>, even for a cache. The item rows are indeed disposable
|
|
/// — worst case they re-pull from a null cursor — but <see cref="UnlockMaterialRow"/> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> options)
|
|
: DbContext(options)
|
|
{
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(configurationBuilder);
|
|
|
|
configurationBuilder.Properties<DateTimeOffset>().HaveConversion<UnixMillisecondsConverter>();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<UnlockMaterialRow>(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();
|
|
});
|
|
|
|
/// <remarks>
|
|
/// A table of its own rather than two more columns on <c>unlock_material</c>, 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.
|
|
/// </remarks>
|
|
private static void ConfigureRememberedSignIn(ModelBuilder modelBuilder) =>
|
|
modelBuilder.Entity<RememberedSignInRow>(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<CachedVaultRow>(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<CachedVaultKeyWrapRow>(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<CachedItemRow>(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<OutboxRow>(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<SyncStateRow>(entity =>
|
|
{
|
|
entity.ToTable("sync_state");
|
|
entity.HasKey(row => row.VaultId);
|
|
entity.Property(row => row.VaultId).ValueGeneratedNever();
|
|
});
|
|
|
|
private static void ConfigureConflicts(ModelBuilder modelBuilder) =>
|
|
modelBuilder.Entity<ConflictRow>(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 });
|
|
});
|
|
}
|