Add the encrypted local cache and the sync client

Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
This commit is contained in:
2026-07-29 10:27:37 +02:00
parent a878c2b6bb
commit 8d2416a602
72 changed files with 11313 additions and 30 deletions
@@ -0,0 +1,73 @@
using System.Text.Json;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Storage;
/// <summary>
/// Translates between the payload columns and <see cref="EncryptedPayload"/>.
/// </summary>
/// <remarks>
/// The columns are nullable because a tombstone has no payload, but the three of them are all-or-
/// nothing: an envelope without its wrapped data key is a row no client can ever open. Reconstructing
/// through here rather than at each call site means that pairing is checked in one place.
/// </remarks>
internal static class CacheMapping
{
internal static EncryptedPayload? ToPayload(
byte[]? envelope,
byte[]? wrappedDataKey,
Guid? dataKeyId,
uint keyGeneration,
byte aadVersion) =>
envelope is null || wrappedDataKey is null || dataKeyId is null
? null
: new EncryptedPayload(envelope, wrappedDataKey, dataKeyId.Value, keyGeneration, aadVersion);
internal static EncryptedPayload? ToAncestorPayload(OutboxRow row) =>
ToPayload(
row.AncestorPayload,
row.AncestorWrappedDataKey,
row.AncestorDataKeyId,
row.AncestorKeyGeneration ?? 0,
row.AncestorAadVersion ?? 0);
}
/// <summary>
/// Serialises the plaintext columns so they can be sealed as one unit.
/// </summary>
/// <remarks>
/// <para>
/// The whole record is sealed together rather than split into columns. Nothing queries these yet — the
/// M1 interface lists every host in a vault — and the moment one field needs an index it gets its own
/// column, at which point the duplication is deliberate and visible rather than pre-emptive.
/// </para>
/// <para>
/// Goes through the Contracts serialiser rather than a hand-rolled encoding, so the local
/// representation cannot drift from the wire one. That matters when re-pushing a change: what the
/// server receives must be what the server sent.
/// </para>
/// </remarks>
internal static class PlaintextFieldsCodec
{
internal static byte[] Encode(SyncPlaintextFields fields) =>
JsonSerializer.SerializeToUtf8Bytes(
fields, DodoSshJsonContext.Default.SyncPlaintextFields);
/// <returns>
/// The fields, or <see langword="null"/> if the bytes are not a record this build understands. A
/// null must degrade to "treat the row as stale and re-pull", never to an exception inside a sync
/// pass.
/// </returns>
internal static SyncPlaintextFields? TryDecode(ReadOnlySpan<byte> utf8)
{
try
{
return JsonSerializer.Deserialize(
utf8, DodoSshJsonContext.Default.SyncPlaintextFields);
}
catch (JsonException)
{
return null;
}
}
}
+283
View File
@@ -0,0 +1,283 @@
using DodoSSH.Contracts;
namespace DodoSSH.Client.Storage;
/// <summary>
/// What unlock needs, and nothing else.
/// </summary>
/// <remarks>
/// <para>
/// A single row: <see cref="Id"/> is always <see cref="SingletonId"/>. One cache database holds one
/// server and one user. Multiple accounts are a real feature and they deserve their own design —
/// which server a vault came from, which identity signed a grant, which profile a window belongs to
/// — rather than a half-provision now that would have to be undone.
/// </para>
/// <para>
/// This is the row that makes an offline launch work. The KDF salt and the wrapped bundle are cached
/// here precisely so that unlock needs no network: fetching a salt at unlock time would mean the
/// vault cannot be opened on a plane, which is the most common moment a user needs it. Neither is a
/// secret — the salt is public by design and the bundle is ciphertext.
/// </para>
/// </remarks>
internal sealed class UnlockMaterialRow
{
/// <summary>The only legal primary key.</summary>
internal const int SingletonId = 1;
public int Id { get; set; } = SingletonId;
public string ServerUrl { get; set; } = string.Empty;
public Guid UserId { get; set; }
public string Issuer { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string? Email { get; set; }
public string? DisplayName { get; set; }
public uint KeyGeneration { get; set; }
/// <summary>The secret bundle, wrapped under the passphrase-derived key. Ciphertext.</summary>
public byte[] WrappedPrivateKey { get; set; } = [];
public string KdfAlgorithm { get; set; } = string.Empty;
public byte[] KdfSalt { get; set; } = [];
/// <summary>Kibibytes, matching both libsodium and the storage column on the server.</summary>
public int KdfMemoryKibibytes { get; set; }
public int KdfPasses { get; set; }
public int KdfParallelism { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
}
/// <summary>A vault the user can reach, with the grant that opens it.</summary>
/// <remarks>
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
/// plaintext here for the same reason it is plaintext on the server: a user has to pick a vault
/// before anything has been decrypted.
/// </remarks>
internal sealed class CachedVaultRow
{
public Guid VaultId { get; set; }
public string Name { get; set; } = string.Empty;
public bool IsPersonal { get; set; }
public Guid? TeamId { get; set; }
public uint KeyGeneration { get; set; }
public int Permissions { get; set; }
/// <summary>
/// The vault key sealed to this user's X25519 key. Null while a grant awaits re-wrap after a
/// rekey, in which case the vault is temporarily unreadable.
/// </summary>
public byte[]? WrappedVaultKey { get; set; }
public bool RekeyRequired { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
}
/// <summary>
/// The last state of an item that the server confirmed.
/// </summary>
/// <remarks>
/// <para>
/// Strictly a mirror: this row is what the server said, never what the user has typed but not yet
/// pushed. Local edits live in <see cref="OutboxRow"/>, which also retains the ancestor they branched
/// from. Keeping the two apart is what makes a three-way merge possible at all — a single row that
/// held "current local state" would have overwritten the common ancestor and left only a two-way
/// diff, which cannot tell an edit from a revert.
/// </para>
/// <para>
/// <see cref="Payload"/> is the server's ciphertext byte for byte, so its AAD still verifies. Storing
/// a re-encrypted copy would work but would throw away the ability to detect that the server handed
/// back something it should not have.
/// </para>
/// </remarks>
internal sealed class CachedItemRow
{
public Guid VaultId { get; set; }
public SyncEntityType EntityType { get; set; }
public Guid EntityId { get; set; }
/// <summary>The server-assigned item version, and the value a push must expect.</summary>
public int Version { get; set; }
public long ChangeSequence { get; set; }
public byte[]? Payload { get; set; }
public byte[]? WrappedDataKey { get; set; }
public Guid? DataKeyId { get; set; }
public uint KeyGeneration { get; set; }
public byte AadVersion { get; set; }
/// <summary>
/// The plaintext columns the server needs, sealed under the LocalCacheKey.
/// </summary>
/// <remarks>
/// Sealed rather than stored as columns because the cache can do better than the server here for
/// free. The server must hold a relay-enabled host's address in the clear — it has to resolve it
/// — but this machine already holds the key that decrypts the payload, so nothing is gained by
/// leaving the address readable in a file that ends up in backups. No query needs these yet; when
/// one does, the field it needs gets its own column and this comment gets revisited.
/// </remarks>
public byte[]? ProtectedFields { get; set; }
/// <summary>A tombstone. Deletes are never hard, or an offline client could not learn of them.</summary>
public bool IsDeleted { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
}
/// <summary>
/// A local change that the server has not yet accepted.
/// </summary>
/// <remarks>
/// <para>
/// At most one row per item, and it carries the ancestor it branched from. That ancestor is the
/// entire reason a conflict can be merged rather than arbitrated: with it, the client can tell which
/// side changed which field.
/// </para>
/// <para>
/// <see cref="Attempts"/> and <see cref="LastError"/> exist so a permanently rejected operation can be
/// parked and shown rather than retried forever. An operation the server calls
/// <c>Invalid</c> will never succeed on retry, and spinning on it would block every change queued
/// behind it.
/// </para>
/// </remarks>
internal sealed class OutboxRow
{
/// <summary>Local, monotonic. Defines the order changes are pushed in.</summary>
public long Sequence { get; set; }
/// <summary>
/// The server's idempotency key for this operation.
/// </summary>
/// <remarks>
/// Re-minted whenever the payload changes — see <c>OutboxStore.QueueAsync</c>. Keeping the old id
/// across an edit would let the server answer <c>Duplicate</c> for an operation whose contents
/// have since changed, silently discarding the newer edit.
/// </remarks>
public Guid OperationId { get; set; }
public Guid VaultId { get; set; }
public SyncEntityType EntityType { get; set; }
public Guid EntityId { get; set; }
public SyncOperation Operation { get; set; }
/// <summary>The version the client believes the server holds. Null means create.</summary>
public int? ExpectedVersion { get; set; }
public byte[]? Payload { get; set; }
public byte[]? WrappedDataKey { get; set; }
public Guid? DataKeyId { get; set; }
public uint KeyGeneration { get; set; }
public byte AadVersion { get; set; }
public byte[]? ProtectedFields { get; set; }
// ---- The ancestor this edit branched from ----
// Kept verbatim, including the fields the AAD binds, because without the generation, the data
// key id and the version, the ancestor cannot be decrypted and the merge has no base.
public int? AncestorVersion { get; set; }
public byte[]? AncestorPayload { get; set; }
public byte[]? AncestorWrappedDataKey { get; set; }
public Guid? AncestorDataKeyId { get; set; }
public uint? AncestorKeyGeneration { get; set; }
public byte? AncestorAadVersion { get; set; }
public byte[]? AncestorProtectedFields { get; set; }
public DateTimeOffset QueuedAtUtc { get; set; }
public int Attempts { get; set; }
public string? LastError { get; set; }
/// <summary>Set when the server rejected this outright, so it stops being retried.</summary>
public bool IsParked { get; set; }
}
/// <summary>Where a vault's pull has reached.</summary>
internal sealed class SyncStateRow
{
public Guid VaultId { get; set; }
/// <summary>
/// The last cursor the server issued. Opaque and integrity-tagged: a client must never
/// construct or edit one, which is why this is stored verbatim and never parsed.
/// </summary>
public string? Cursor { get; set; }
public uint KeyGeneration { get; set; }
public DateTimeOffset? LastPulledAtUtc { get; set; }
public DateTimeOffset? LastPushedAtUtc { get; set; }
/// <summary>
/// Observed difference between the server's clock and this machine's, from the last pull.
/// </summary>
/// <remarks>
/// Recorded rather than corrected. Local timestamps are display metadata, never a merge input —
/// the merge uses versions and the retained ancestor — so a skewed clock must not be able to
/// decide which edit wins.
/// </remarks>
public long ServerTimeSkewMs { get; set; }
}
/// <summary>Something the merge had to override, or an item that could not be processed.</summary>
internal sealed class ConflictRow
{
public Guid Id { get; set; }
public Guid VaultId { get; set; }
public SyncEntityType EntityType { get; set; }
public Guid EntityId { get; set; }
public ConflictKind Kind { get; set; }
/// <summary>The discarded values, sealed under the LocalCacheKey.</summary>
/// <remarks>
/// Sealed because this is the one place the cache deliberately holds decrypted vault content: the
/// value a merge overrode. It has to be readable to be useful and it is exactly as sensitive as
/// the item it came from.
/// </remarks>
public byte[] Detail { get; set; } = [];
public DateTimeOffset DetectedAtUtc { get; set; }
public bool Acknowledged { get; set; }
}
@@ -0,0 +1,165 @@
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);
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();
});
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();
});
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 });
});
}
@@ -0,0 +1,133 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace DodoSSH.Client.Storage;
/// <summary>
/// Opens the local cache and hands out short-lived contexts.
/// </summary>
/// <remarks>
/// <para>
/// A factory rather than one long-lived context, because a sync pass runs on a background task while
/// the interface reads the same tables, and a <see cref="DbContext"/> is not thread-safe. Each store
/// operation takes a context, does one unit of work and disposes it; SQLite serialises the writes.
/// </para>
/// <para>
/// The alternative — a single context guarded by a lock — would work and would also silently
/// accumulate a change tracker for the life of the process, which for a vault of thousands of items
/// is both a leak and a source of stale reads.
/// </para>
/// </remarks>
public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>, IDisposable
{
private readonly DbContextOptions<ClientCacheContext> options;
/// <remarks>
/// An in-memory SQLite database exists only while at least one connection to it is open, so the
/// memory-backed factory holds one for its lifetime. Null for a file-backed one.
/// </remarks>
private readonly SqliteConnection? keepAlive;
private bool disposed;
private ClientCacheFactory(string connectionString, SqliteConnection? keepAlive)
{
this.keepAlive = keepAlive;
options = new DbContextOptionsBuilder<ClientCacheContext>()
.UseSqlite(connectionString)
.UseSnakeCaseNamingConvention()
.Options;
}
/// <summary>Opens, or creates, a cache file.</summary>
/// <param name="databasePath">Full path to the SQLite file.</param>
public static ClientCacheFactory ForFile(string databasePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(databasePath);
var builder = new SqliteConnectionStringBuilder
{
DataSource = databasePath,
// The cache is written by one process. WAL would buy concurrent readers we do not have
// and would leave two extra files beside the database for a user to wonder about.
Pooling = true,
};
return new ClientCacheFactory(builder.ConnectionString, keepAlive: null);
}
/// <summary>
/// Opens a private in-memory cache, for tests and for a session that must leave no trace.
/// </summary>
/// <param name="name">
/// Distinguishes one in-memory database from another. Two factories given the same name share
/// storage, which is how a test can prove that data survives a context being disposed.
/// </param>
public static ClientCacheFactory ForMemory(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var builder = new SqliteConnectionStringBuilder
{
DataSource = name,
Mode = SqliteOpenMode.Memory,
Cache = SqliteCacheMode.Shared,
};
var connection = new SqliteConnection(builder.ConnectionString);
connection.Open();
return new ClientCacheFactory(builder.ConnectionString, connection);
}
/// <inheritdoc />
public ClientCacheContext CreateDbContext()
{
ObjectDisposedException.ThrowIf(disposed, this);
return new ClientCacheContext(options);
}
/// <summary>
/// Brings the schema up to date.
/// </summary>
/// <remarks>
/// Called by the client at startup, before unlock — it touches no encrypted content, only the
/// shape of the tables. It must therefore never need a key, which is also why the schema is
/// migrated rather than recreated.
/// </remarks>
public async Task MigrateAsync(CancellationToken cancellationToken)
{
var context = CreateDbContext();
await using var scope = context.ConfigureAwait(false);
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
keepAlive?.Dispose();
}
}
/// <summary>
/// Supplies a context to <c>dotnet ef</c>.
/// </summary>
/// <remarks>
/// Exists only for the migrations tooling, which needs to build a model without running the
/// application. The path is a throwaway: the tool reads the model, not the data.
/// </remarks>
public sealed class ClientCacheDesignTimeFactory : IDesignTimeDbContextFactory<ClientCacheContext>
{
/// <inheritdoc />
public ClientCacheContext CreateDbContext(string[] args) =>
ClientCacheFactory.ForFile("dodossh-design-time.db").CreateDbContext();
}
+142
View File
@@ -0,0 +1,142 @@
using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>
/// What the merge had to override, and what it could not process.
/// </summary>
/// <remarks>
/// <para>
/// This table is what makes automatic merging defensible. The merge picks a winner field by field,
/// which is only acceptable because the loser lands here verbatim and gets shown. Without it, a
/// field-level merge is last-writer-wins with a longer explanation.
/// </para>
/// <para>
/// The detail is sealed under the LocalCacheKey, because it is the one place the cache deliberately
/// holds decrypted vault content — a password someone typed that another edit displaced. It is exactly
/// as sensitive as the item it came from and is treated that way.
/// </para>
/// </remarks>
public sealed class ConflictStore(
IDbContextFactory<ClientCacheContext> contexts,
LocalCacheProtector protector,
TimeProvider clock)
{
/// <summary>
/// Records a conflict.
/// </summary>
/// <remarks>
/// The record's own id is generated here and the detail is bound to it, so one conflict's discarded
/// values can never be read back against another's row.
/// </remarks>
public async Task<Guid> RecordAsync(
Guid vaultId,
SyncEntityType entityType,
Guid entityId,
ConflictKind kind,
ReadOnlyMemory<byte> detail,
CancellationToken cancellationToken)
{
if (kind == ConflictKind.Unspecified)
{
throw new ArgumentOutOfRangeException(nameof(kind), kind, "A conflict kind is required.");
}
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var id = Guid.CreateVersion7();
context.Add(new ConflictRow
{
Id = id,
VaultId = vaultId,
EntityType = entityType,
EntityId = entityId,
Kind = kind,
Detail = protector.Protect(AadResourceTypes.For(entityType), id, detail.Span),
DetectedAtUtc = clock.GetUtcNow(),
Acknowledged = false,
});
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return id;
}
/// <summary>Reads conflicts for a vault, newest first.</summary>
public async Task<IReadOnlyList<StoredConflict>> ListAsync(
Guid vaultId,
bool includeAcknowledged,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var query = context.Set<ConflictRow>()
.AsNoTracking()
.Where(row => row.VaultId == vaultId);
if (!includeAcknowledged)
{
query = query.Where(row => !row.Acknowledged);
}
var rows = await query
.OrderByDescending(row => row.DetectedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToStored)];
}
/// <summary>Marks a conflict as dealt with.</summary>
/// <remarks>
/// Acknowledged rather than deleted, so the discarded value stays recoverable after the user has
/// dismissed the notification. Someone who clicks past a warning and realises a minute later that
/// they wanted the other value should still be able to get it.
/// </remarks>
public async Task<bool> AcknowledgeAsync(Guid conflictId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var updated = await context.Set<ConflictRow>()
.Where(row => row.Id == conflictId)
.ExecuteUpdateAsync(row => row.SetProperty(r => r.Acknowledged, true), cancellationToken)
.ConfigureAwait(false);
return updated > 0;
}
/// <summary>Removes an acknowledged conflict for good.</summary>
public async Task<bool> DiscardAsync(Guid conflictId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var removed = await context.Set<ConflictRow>()
.Where(row => row.Id == conflictId && row.Acknowledged)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return removed > 0;
}
/// <remarks>
/// A detail that will not open surfaces as empty rather than as a failure. The conflict itself — its
/// kind, its item, its timestamp — is still worth showing even when the discarded value has become
/// unreadable, for instance after a passphrase change re-derived the cache key.
/// </remarks>
private StoredConflict ToStored(ConflictRow row) =>
new(
row.Id,
row.VaultId,
row.EntityType,
row.EntityId,
row.Kind,
protector.TryUnprotect(AadResourceTypes.For(row.EntityType), row.Id, row.Detail) ?? [],
row.DetectedAtUtc,
row.Acknowledged);
}
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The client's local cache: ciphertext exactly as the server returned it, the outbox of local
changes not yet accepted, and the material an offline unlock needs.
SQLite through EF Core, and deliberately *not* SQLCipher. Item payloads arrive already
encrypted under keys the server has never seen, so an encrypted database file would protect
bytes that are protected already, at the cost of a native dependency and a licence obligation.
The one thing that would genuinely be plaintext — a search index — is kept in memory and
rebuilt on unlock. SQLitePCLRaw deprecated bundle_e_sqlcipher in 3.0 in any case.
Everything this project does store in the clear is either not a secret (a version number, a
change sequence) or is sealed under the LocalCacheKey first.
-->
<ItemGroup>
<ProjectReference Include="../DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
<ProjectReference Include="../DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="EFCore.NamingConventions" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Client.Storage.Tests" />
<InternalsVisibleTo Include="DodoSSH.Client.Sync" />
<InternalsVisibleTo Include="DodoSSH.Client.Sync.Tests" />
</ItemGroup>
</Project>
+192
View File
@@ -0,0 +1,192 @@
using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>
/// The mirror of what the server holds.
/// </summary>
/// <remarks>
/// <para>
/// Every row here is server-confirmed state. Nothing the user has typed but not yet pushed appears in
/// this table — that lives in <see cref="OutboxStore"/>, together with the ancestor it branched from.
/// Keeping the two apart is what makes a three-way merge possible: a single table holding "the current
/// local view" would have overwritten the ancestor and left only a two-way diff, which cannot tell an
/// edit from a revert.
/// </para>
/// <para>
/// Requires an unlocked <see cref="LocalCacheProtector"/>, which is deliberate. The protected columns
/// have to be sealed on every write and opened on every read, and a store that could be constructed
/// without a key would be a store that could write one of them in the clear.
/// </para>
/// </remarks>
public sealed class ItemStore(
IDbContextFactory<ClientCacheContext> contexts,
LocalCacheProtector protector)
{
/// <summary>Reads one item, tombstones included.</summary>
public async Task<StoredItem?> FindAsync(
Guid vaultId,
SyncEntityType entityType,
Guid entityId,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<CachedItemRow>()
.AsNoTracking()
.SingleOrDefaultAsync(
r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToStored(row);
}
/// <summary>Reads every item of one kind in a vault.</summary>
/// <param name="vaultId">The vault.</param>
/// <param name="entityType">Kind of item.</param>
/// <param name="includeDeleted">
/// Whether to return tombstones. The interface wants them excluded; the sync engine wants them,
/// because a tombstone is the only record that an item it once knew about has gone.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<IReadOnlyList<StoredItem>> ListAsync(
Guid vaultId,
SyncEntityType entityType,
bool includeDeleted,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var query = context.Set<CachedItemRow>()
.AsNoTracking()
.Where(r => r.VaultId == vaultId && r.EntityType == entityType);
if (!includeDeleted)
{
query = query.Where(r => !r.IsDeleted);
}
var rows = await query
.OrderBy(r => r.ChangeSequence)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToStored)];
}
/// <summary>
/// Writes the server's version of an item, creating or replacing the row.
/// </summary>
/// <remarks>
/// Deliberately a blind overwrite. This is a mirror, and the server's answer is the truth about
/// what the server holds; a local edit that must survive is in the outbox, and it is the sync
/// engine's job to have merged it before calling this.
/// </remarks>
public async Task SaveAsync(StoredItem item, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(item);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<CachedItemRow>()
.SingleOrDefaultAsync(
r => r.VaultId == item.VaultId
&& r.EntityType == item.EntityType
&& r.EntityId == item.EntityId,
cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new CachedItemRow
{
VaultId = item.VaultId,
EntityType = item.EntityType,
EntityId = item.EntityId,
};
context.Add(row);
}
Apply(row, item);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary>
/// <remarks>
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make
/// it indistinguishable from one this client has never seen, and it would silently reappear on the
/// next full pull.
/// </remarks>
public async Task<int> CollectTombstonesAsync(
Guid vaultId,
DateTimeOffset olderThan,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<CachedItemRow>()
.Where(r => r.VaultId == vaultId && r.IsDeleted && r.UpdatedAtUtc < olderThan)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
private void Apply(CachedItemRow row, StoredItem item)
{
row.Version = item.Version;
row.ChangeSequence = item.ChangeSequence;
row.IsDeleted = item.IsDeleted;
row.UpdatedAtUtc = item.UpdatedAt;
row.Payload = item.Payload?.Envelope;
row.WrappedDataKey = item.Payload?.WrappedDataKey;
row.DataKeyId = item.Payload?.DataKeyId;
row.KeyGeneration = item.Payload?.KeyGeneration ?? 0;
row.AadVersion = item.Payload?.AadVersion ?? 0;
row.ProtectedFields = item.Fields is null
? null
: protector.Protect(
AadResourceTypes.For(item.EntityType),
item.EntityId,
PlaintextFieldsCodec.Encode(item.Fields));
}
private StoredItem ToStored(CachedItemRow row) =>
new(
row.VaultId,
row.EntityType,
row.EntityId,
row.Version,
row.ChangeSequence,
CacheMapping.ToPayload(
row.Payload, row.WrappedDataKey, row.DataKeyId, row.KeyGeneration, row.AadVersion),
OpenFields(row.EntityType, row.EntityId, row.ProtectedFields),
row.IsDeleted,
row.UpdatedAtUtc);
/// <remarks>
/// A record that will not open is treated as absent rather than fatal. The cache is not the
/// authority — a re-pull restores it — and the alternative is one stale row aborting a sync pass
/// and stranding every change behind it.
/// </remarks>
private SyncPlaintextFields? OpenFields(SyncEntityType entityType, Guid entityId, byte[]? sealedFields)
{
if (sealedFields is null)
{
return null;
}
var plaintext = protector.TryUnprotect(
AadResourceTypes.For(entityType), entityId, sealedFields);
return plaintext is null ? null : PlaintextFieldsCodec.TryDecode(plaintext);
}
}
@@ -0,0 +1,122 @@
using System.Security.Cryptography;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Storage;
/// <summary>
/// Seals the few things the local cache holds that are not already ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// The cache stores item payloads exactly as the server sent them, so they need no further
/// protection. Two things do: the plaintext columns the server needs — a relay-enabled host's
/// address, chiefly — and the values a merge overrode, which are decrypted vault content by
/// definition. Both go through here.
/// </para>
/// <para>
/// <b>What this is and is not worth.</b> The key derives from the master key, so it exists only while
/// the vault is unlocked and is never written anywhere. That makes a stolen laptop, a stray backup or
/// a synced-to-cloud application folder yield nothing — which is the threat this addresses. It does
/// <em>not</em> defend against a process running as the same user: that process can read this
/// process's memory, and no on-disk measure changes it. docs/crypto.md §10 says the same about a
/// compromised endpoint, and this layer does not pretend otherwise.
/// </para>
/// <para>
/// Every record is bound to its own row, so a record cannot be moved to a different row of the same
/// cache. For a relay address that is not academic: two swapped rows would aim one host's connection
/// at another host's address.
/// </para>
/// </remarks>
public sealed class LocalCacheProtector : IDisposable
{
private readonly byte[] key = new byte[CryptoSpec.SymmetricKeySize];
private bool disposed;
private LocalCacheProtector(MasterKey master) => master.DeriveLocalCacheKey(key);
/// <summary>
/// Derives the cache key from an unlocked master key.
/// </summary>
/// <remarks>
/// The master key is not retained. Only the subkey is, and it is domain-separated by its HKDF
/// label from the key that wraps the secret bundle — the two live in very different threat models
/// and must not be the same bytes.
/// </remarks>
public static LocalCacheProtector From(MasterKey master)
{
ArgumentNullException.ThrowIfNull(master);
return new LocalCacheProtector(master);
}
/// <summary>Seals a cache record, binding it to the row that will hold it.</summary>
public byte[] Protect(
CryptoSpec.AadResourceType resourceType,
Guid recordId,
ReadOnlySpan<byte> plaintext)
{
ObjectDisposedException.ThrowIf(disposed, this);
return DshCrypto.Seal(key, plaintext, DshAad.LocalCache(resourceType, recordId));
}
/// <summary>
/// Opens a sealed cache record.
/// </summary>
/// <returns>
/// The plaintext, or <see langword="null"/> if the record does not belong to this row or this
/// user. Null rather than an exception because a stale cache file is an ordinary situation — a
/// changed passphrase re-derives a different key — and the caller's answer is to discard the row
/// and re-pull, not to fail.
/// </returns>
public byte[]? TryUnprotect(
CryptoSpec.AadResourceType resourceType,
Guid recordId,
ReadOnlySpan<byte> envelope)
{
ObjectDisposedException.ThrowIf(disposed, this);
return DshCrypto.Open(key, envelope, DshAad.LocalCache(resourceType, recordId));
}
/// <inheritdoc />
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
CryptographicOperations.ZeroMemory(key);
}
}
/// <summary>
/// Maps a syncable entity type onto the resource type its AAD binds.
/// </summary>
/// <remarks>
/// A switch rather than a cast, even though the two enums happen to be adjacent. They are not the
/// same list: <see cref="CryptoSpec.AadResourceType"/> also covers users, devices and vaults, so the
/// numbers do not line up, and a cast would bind an item's ciphertext to the wrong resource type
/// without failing anywhere a test would notice.
/// </remarks>
internal static class AadResourceTypes
{
internal static CryptoSpec.AadResourceType For(SyncEntityType entityType) => entityType switch
{
SyncEntityType.Host => CryptoSpec.AadResourceType.Host,
SyncEntityType.Credential => CryptoSpec.AadResourceType.Credential,
SyncEntityType.SshKey => CryptoSpec.AadResourceType.SshKey,
SyncEntityType.HostGroup => CryptoSpec.AadResourceType.HostGroup,
SyncEntityType.Tag => CryptoSpec.AadResourceType.Tag,
SyncEntityType.HostTag => CryptoSpec.AadResourceType.HostTag,
SyncEntityType.HostCredential => CryptoSpec.AadResourceType.HostCredential,
SyncEntityType.Snippet => CryptoSpec.AadResourceType.Snippet,
SyncEntityType.PortForward => CryptoSpec.AadResourceType.PortForward,
SyncEntityType.KnownHostKey => CryptoSpec.AadResourceType.KnownHostKey,
_ => throw new ArgumentOutOfRangeException(
nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
};
}
@@ -0,0 +1,408 @@
// <auto-generated />
using System;
using DodoSSH.Client.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
[DbContext(typeof(ClientCacheContext))]
[Migration("20260729080003_InitialCache")]
partial class InitialCache
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<long>("ChangeSequence")
.HasColumnType("INTEGER")
.HasColumnName("change_sequence");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER")
.HasColumnName("is_deleted");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<int>("Version")
.HasColumnType("INTEGER")
.HasColumnName("version");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("VaultId", "EntityType", "EntityId")
.HasName("pk_item");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_item_vault_id_change_sequence");
b.HasIndex("VaultId", "EntityType")
.HasDatabaseName("ix_item_vault_id_entity_type");
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<bool>("IsPersonal")
.HasColumnType("INTEGER")
.HasColumnName("is_personal");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Permissions")
.HasColumnType("INTEGER")
.HasColumnName("permissions");
b.Property<bool>("RekeyRequired")
.HasColumnType("INTEGER")
.HasColumnName("rekey_required");
b.Property<Guid?>("TeamId")
.HasColumnType("TEXT")
.HasColumnName("team_id");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<byte[]>("WrappedVaultKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_vault_key");
b.HasKey("VaultId")
.HasName("pk_vault");
b.ToTable("vault", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<byte[]>("Detail")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("detail");
b.Property<long>("DetectedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("detected_at_utc");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int>("Kind")
.HasColumnType("INTEGER")
.HasColumnName("kind");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.HasKey("Id")
.HasName("pk_conflict");
b.HasIndex("VaultId", "Acknowledged")
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
b.HasIndex("VaultId", "EntityType", "EntityId")
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
b.ToTable("conflict", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("sequence");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<byte?>("AncestorAadVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_aad_version");
b.Property<Guid?>("AncestorDataKeyId")
.HasColumnType("TEXT")
.HasColumnName("ancestor_data_key_id");
b.Property<uint?>("AncestorKeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_key_generation");
b.Property<byte[]>("AncestorPayload")
.HasColumnType("BLOB")
.HasColumnName("ancestor_payload");
b.Property<byte[]>("AncestorProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("ancestor_protected_fields");
b.Property<int?>("AncestorVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_version");
b.Property<byte[]>("AncestorWrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("ancestor_wrapped_data_key");
b.Property<int>("Attempts")
.HasColumnType("INTEGER")
.HasColumnName("attempts");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int?>("ExpectedVersion")
.HasColumnType("INTEGER")
.HasColumnName("expected_version");
b.Property<bool>("IsParked")
.HasColumnType("INTEGER")
.HasColumnName("is_parked");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("LastError")
.HasColumnType("TEXT")
.HasColumnName("last_error");
b.Property<int>("Operation")
.HasColumnType("INTEGER")
.HasColumnName("operation");
b.Property<Guid>("OperationId")
.HasColumnType("TEXT")
.HasColumnName("operation_id");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("QueuedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("queued_at_utc");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("Sequence")
.HasName("pk_outbox");
b.HasIndex("OperationId")
.IsUnique()
.HasDatabaseName("ix_outbox_operation_id");
b.HasIndex("VaultId", "EntityType", "EntityId")
.IsUnique()
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
b.HasIndex("VaultId", "IsParked", "Sequence")
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
b.ToTable("outbox", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<string>("Cursor")
.HasColumnType("TEXT")
.HasColumnName("cursor");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<long?>("LastPulledAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pulled_at_utc");
b.Property<long?>("LastPushedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
b.HasKey("VaultId")
.HasName("pk_sync_state");
b.ToTable("sync_state", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("DisplayName")
.HasColumnType("TEXT")
.HasColumnName("display_name");
b.Property<string>("Email")
.HasColumnType("TEXT")
.HasColumnName("email");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("issuer");
b.Property<string>("KdfAlgorithm")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kdf_algorithm");
b.Property<int>("KdfMemoryKibibytes")
.HasColumnType("INTEGER")
.HasColumnName("kdf_memory_kibibytes");
b.Property<int>("KdfParallelism")
.HasColumnType("INTEGER")
.HasColumnName("kdf_parallelism");
b.Property<int>("KdfPasses")
.HasColumnType("INTEGER")
.HasColumnName("kdf_passes");
b.Property<byte[]>("KdfSalt")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("kdf_salt");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subject");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.Property<byte[]>("WrappedPrivateKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_private_key");
b.HasKey("Id")
.HasName("pk_unlock_material");
b.ToTable("unlock_material", null, t =>
{
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,211 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
/// <inheritdoc />
public partial class InitialCache : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "conflict",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
entity_type = table.Column<int>(type: "INTEGER", nullable: false),
entity_id = table.Column<Guid>(type: "TEXT", nullable: false),
kind = table.Column<int>(type: "INTEGER", nullable: false),
detail = table.Column<byte[]>(type: "BLOB", nullable: false),
detected_at_utc = table.Column<long>(type: "INTEGER", nullable: false),
acknowledged = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_conflict", x => x.id);
});
migrationBuilder.CreateTable(
name: "item",
columns: table => new
{
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
entity_type = table.Column<int>(type: "INTEGER", nullable: false),
entity_id = table.Column<Guid>(type: "TEXT", nullable: false),
version = table.Column<int>(type: "INTEGER", nullable: false),
change_sequence = table.Column<long>(type: "INTEGER", nullable: false),
payload = table.Column<byte[]>(type: "BLOB", nullable: true),
wrapped_data_key = table.Column<byte[]>(type: "BLOB", nullable: true),
data_key_id = table.Column<Guid>(type: "TEXT", nullable: true),
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
aad_version = table.Column<byte>(type: "INTEGER", nullable: false),
protected_fields = table.Column<byte[]>(type: "BLOB", nullable: true),
is_deleted = table.Column<bool>(type: "INTEGER", nullable: false),
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_item", x => new { x.vault_id, x.entity_type, x.entity_id });
});
migrationBuilder.CreateTable(
name: "outbox",
columns: table => new
{
sequence = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
operation_id = table.Column<Guid>(type: "TEXT", nullable: false),
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
entity_type = table.Column<int>(type: "INTEGER", nullable: false),
entity_id = table.Column<Guid>(type: "TEXT", nullable: false),
operation = table.Column<int>(type: "INTEGER", nullable: false),
expected_version = table.Column<int>(type: "INTEGER", nullable: true),
payload = table.Column<byte[]>(type: "BLOB", nullable: true),
wrapped_data_key = table.Column<byte[]>(type: "BLOB", nullable: true),
data_key_id = table.Column<Guid>(type: "TEXT", nullable: true),
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
aad_version = table.Column<byte>(type: "INTEGER", nullable: false),
protected_fields = table.Column<byte[]>(type: "BLOB", nullable: true),
ancestor_version = table.Column<int>(type: "INTEGER", nullable: true),
ancestor_payload = table.Column<byte[]>(type: "BLOB", nullable: true),
ancestor_wrapped_data_key = table.Column<byte[]>(type: "BLOB", nullable: true),
ancestor_data_key_id = table.Column<Guid>(type: "TEXT", nullable: true),
ancestor_key_generation = table.Column<uint>(type: "INTEGER", nullable: true),
ancestor_aad_version = table.Column<byte>(type: "INTEGER", nullable: true),
ancestor_protected_fields = table.Column<byte[]>(type: "BLOB", nullable: true),
queued_at_utc = table.Column<long>(type: "INTEGER", nullable: false),
attempts = table.Column<int>(type: "INTEGER", nullable: false),
last_error = table.Column<string>(type: "TEXT", nullable: true),
is_parked = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_outbox", x => x.sequence);
});
migrationBuilder.CreateTable(
name: "sync_state",
columns: table => new
{
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
cursor = table.Column<string>(type: "TEXT", nullable: true),
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
last_pulled_at_utc = table.Column<long>(type: "INTEGER", nullable: true),
last_pushed_at_utc = table.Column<long>(type: "INTEGER", nullable: true),
server_time_skew_ms = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_sync_state", x => x.vault_id);
});
migrationBuilder.CreateTable(
name: "unlock_material",
columns: table => new
{
id = table.Column<int>(type: "INTEGER", nullable: false),
server_url = table.Column<string>(type: "TEXT", nullable: false),
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
issuer = table.Column<string>(type: "TEXT", nullable: false),
subject = table.Column<string>(type: "TEXT", nullable: false),
email = table.Column<string>(type: "TEXT", nullable: true),
display_name = table.Column<string>(type: "TEXT", nullable: true),
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
wrapped_private_key = table.Column<byte[]>(type: "BLOB", nullable: false),
kdf_algorithm = table.Column<string>(type: "TEXT", nullable: false),
kdf_salt = table.Column<byte[]>(type: "BLOB", nullable: false),
kdf_memory_kibibytes = table.Column<int>(type: "INTEGER", nullable: false),
kdf_passes = table.Column<int>(type: "INTEGER", nullable: false),
kdf_parallelism = table.Column<int>(type: "INTEGER", nullable: false),
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_unlock_material", x => x.id);
table.CheckConstraint("ck_unlock_material_singleton", "id = 1");
});
migrationBuilder.CreateTable(
name: "vault",
columns: table => new
{
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
is_personal = table.Column<bool>(type: "INTEGER", nullable: false),
team_id = table.Column<Guid>(type: "TEXT", nullable: true),
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
permissions = table.Column<int>(type: "INTEGER", nullable: false),
wrapped_vault_key = table.Column<byte[]>(type: "BLOB", nullable: true),
rekey_required = table.Column<bool>(type: "INTEGER", nullable: false),
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_vault", x => x.vault_id);
});
migrationBuilder.CreateIndex(
name: "ix_conflict_vault_id_acknowledged",
table: "conflict",
columns: new[] { "vault_id", "acknowledged" });
migrationBuilder.CreateIndex(
name: "ix_conflict_vault_id_entity_type_entity_id",
table: "conflict",
columns: new[] { "vault_id", "entity_type", "entity_id" });
migrationBuilder.CreateIndex(
name: "ix_item_vault_id_change_sequence",
table: "item",
columns: new[] { "vault_id", "change_sequence" });
migrationBuilder.CreateIndex(
name: "ix_item_vault_id_entity_type",
table: "item",
columns: new[] { "vault_id", "entity_type" });
migrationBuilder.CreateIndex(
name: "ix_outbox_operation_id",
table: "outbox",
column: "operation_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_outbox_vault_id_entity_type_entity_id",
table: "outbox",
columns: new[] { "vault_id", "entity_type", "entity_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_outbox_vault_id_is_parked_sequence",
table: "outbox",
columns: new[] { "vault_id", "is_parked", "sequence" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "conflict");
migrationBuilder.DropTable(
name: "item");
migrationBuilder.DropTable(
name: "outbox");
migrationBuilder.DropTable(
name: "sync_state");
migrationBuilder.DropTable(
name: "unlock_material");
migrationBuilder.DropTable(
name: "vault");
}
}
}
@@ -0,0 +1,405 @@
// <auto-generated />
using System;
using DodoSSH.Client.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
[DbContext(typeof(ClientCacheContext))]
partial class ClientCacheContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<long>("ChangeSequence")
.HasColumnType("INTEGER")
.HasColumnName("change_sequence");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER")
.HasColumnName("is_deleted");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<int>("Version")
.HasColumnType("INTEGER")
.HasColumnName("version");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("VaultId", "EntityType", "EntityId")
.HasName("pk_item");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_item_vault_id_change_sequence");
b.HasIndex("VaultId", "EntityType")
.HasDatabaseName("ix_item_vault_id_entity_type");
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<bool>("IsPersonal")
.HasColumnType("INTEGER")
.HasColumnName("is_personal");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Permissions")
.HasColumnType("INTEGER")
.HasColumnName("permissions");
b.Property<bool>("RekeyRequired")
.HasColumnType("INTEGER")
.HasColumnName("rekey_required");
b.Property<Guid?>("TeamId")
.HasColumnType("TEXT")
.HasColumnName("team_id");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<byte[]>("WrappedVaultKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_vault_key");
b.HasKey("VaultId")
.HasName("pk_vault");
b.ToTable("vault", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<byte[]>("Detail")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("detail");
b.Property<long>("DetectedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("detected_at_utc");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int>("Kind")
.HasColumnType("INTEGER")
.HasColumnName("kind");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.HasKey("Id")
.HasName("pk_conflict");
b.HasIndex("VaultId", "Acknowledged")
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
b.HasIndex("VaultId", "EntityType", "EntityId")
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
b.ToTable("conflict", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("sequence");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<byte?>("AncestorAadVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_aad_version");
b.Property<Guid?>("AncestorDataKeyId")
.HasColumnType("TEXT")
.HasColumnName("ancestor_data_key_id");
b.Property<uint?>("AncestorKeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_key_generation");
b.Property<byte[]>("AncestorPayload")
.HasColumnType("BLOB")
.HasColumnName("ancestor_payload");
b.Property<byte[]>("AncestorProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("ancestor_protected_fields");
b.Property<int?>("AncestorVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_version");
b.Property<byte[]>("AncestorWrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("ancestor_wrapped_data_key");
b.Property<int>("Attempts")
.HasColumnType("INTEGER")
.HasColumnName("attempts");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int?>("ExpectedVersion")
.HasColumnType("INTEGER")
.HasColumnName("expected_version");
b.Property<bool>("IsParked")
.HasColumnType("INTEGER")
.HasColumnName("is_parked");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("LastError")
.HasColumnType("TEXT")
.HasColumnName("last_error");
b.Property<int>("Operation")
.HasColumnType("INTEGER")
.HasColumnName("operation");
b.Property<Guid>("OperationId")
.HasColumnType("TEXT")
.HasColumnName("operation_id");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("QueuedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("queued_at_utc");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("Sequence")
.HasName("pk_outbox");
b.HasIndex("OperationId")
.IsUnique()
.HasDatabaseName("ix_outbox_operation_id");
b.HasIndex("VaultId", "EntityType", "EntityId")
.IsUnique()
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
b.HasIndex("VaultId", "IsParked", "Sequence")
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
b.ToTable("outbox", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<string>("Cursor")
.HasColumnType("TEXT")
.HasColumnName("cursor");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<long?>("LastPulledAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pulled_at_utc");
b.Property<long?>("LastPushedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
b.HasKey("VaultId")
.HasName("pk_sync_state");
b.ToTable("sync_state", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("DisplayName")
.HasColumnType("TEXT")
.HasColumnName("display_name");
b.Property<string>("Email")
.HasColumnType("TEXT")
.HasColumnName("email");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("issuer");
b.Property<string>("KdfAlgorithm")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kdf_algorithm");
b.Property<int>("KdfMemoryKibibytes")
.HasColumnType("INTEGER")
.HasColumnName("kdf_memory_kibibytes");
b.Property<int>("KdfParallelism")
.HasColumnType("INTEGER")
.HasColumnName("kdf_parallelism");
b.Property<int>("KdfPasses")
.HasColumnType("INTEGER")
.HasColumnName("kdf_passes");
b.Property<byte[]>("KdfSalt")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("kdf_salt");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subject");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.Property<byte[]>("WrappedPrivateKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_private_key");
b.HasKey("Id")
.HasName("pk_unlock_material");
b.ToTable("unlock_material", null, t =>
{
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
});
});
#pragma warning restore 612, 618
}
}
}
+403
View File
@@ -0,0 +1,403 @@
using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>A local change to queue.</summary>
/// <param name="VaultId">Owning vault.</param>
/// <param name="EntityType">Kind of item.</param>
/// <param name="EntityId">The item. Client-generated UUIDv7, so items can be made offline.</param>
/// <param name="Operation">Upsert or delete.</param>
/// <param name="ExpectedVersion">The version the client believes the server holds; null to create.</param>
/// <param name="Payload">Ciphertext. Required for an upsert.</param>
/// <param name="Fields">Plaintext columns the server needs.</param>
/// <param name="Ancestor">
/// The version this edit branched from, so a conflict can be merged rather than arbitrated. Null when
/// creating, where there is nothing to have branched from.
/// </param>
public sealed record QueuedChange(
Guid VaultId,
SyncEntityType EntityType,
Guid EntityId,
SyncOperation Operation,
int? ExpectedVersion,
EncryptedPayload? Payload,
SyncPlaintextFields? Fields,
StoredAncestor? Ancestor);
/// <summary>
/// Changes made here that the server has not yet accepted.
/// </summary>
/// <remarks>
/// <para>
/// One row per item, and that is a database constraint rather than a convention. Two queued edits to
/// one item would have to be pushed in order, and the second's <c>expectedVersion</c> is the version
/// the first will produce — which is not known when it is queued. Coalescing sidesteps that instead of
/// managing it, and the row holds a desired end state rather than a delta, so coalescing loses nothing.
/// </para>
/// <para>
/// <b>Why a coalesced row gets a new operation id.</b> The id is the server's exactly-once key. If a
/// push has already gone out and the user edits again, keeping the id would let the server answer
/// <c>Duplicate</c> for an operation whose contents have since changed — silently discarding the newer
/// edit. A fresh id means the newer state is offered on its own terms: if the earlier push did land,
/// the version has moved on, the push comes back <c>Conflict</c>, and the merge resolves it against an
/// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges
/// on the newest state with nothing for the user to arbitrate.
/// </para>
/// </remarks>
public sealed class OutboxStore(
IDbContextFactory<ClientCacheContext> contexts,
LocalCacheProtector protector,
TimeProvider clock)
{
/// <summary>
/// Queues a change the user just made, coalescing into any row already pending for the item.
/// </summary>
/// <remarks>
/// A coalesced row keeps the ancestor and <c>expectedVersion</c> of the row it replaces, because
/// the new state is still a descendant of that same base. Taking the caller's values instead would
/// throw away the common ancestor after the first edit, and with it the ability to merge.
/// </remarks>
public async Task<PendingOperation> QueueAsync(
QueuedChange change,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(change);
Validate(change);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await FindRowAsync(context, change.VaultId, change.EntityType, change.EntityId, cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new OutboxRow
{
VaultId = change.VaultId,
EntityType = change.EntityType,
EntityId = change.EntityId,
ExpectedVersion = change.ExpectedVersion,
};
SetAncestor(row, change.EntityType, change.EntityId, change.Ancestor);
context.Add(row);
}
row.OperationId = Guid.CreateVersion7();
row.Operation = change.Operation;
row.QueuedAtUtc = clock.GetUtcNow();
row.Attempts = 0;
row.LastError = null;
row.IsParked = false;
SetPayload(row, change.EntityType, change.EntityId, change.Payload, change.Fields);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return ToPending(row);
}
/// <summary>
/// Replaces a pending operation with the outcome of a merge.
/// </summary>
/// <remarks>
/// <para>
/// Distinct from <see cref="QueueAsync"/> because the intent is opposite: this <em>does</em> move
/// the ancestor forward, to the server version the merge was performed against. Without that the
/// re-push would conflict against the same base for ever.
/// </para>
/// <para>
/// It also <b>keeps the attempt count</b>, where queueing resets it. That difference is what makes
/// the retry bound real: a row that has conflicted five times needs a person to look at it whether
/// or not each attempt carried a freshly merged payload, whereas a user making a new edit has
/// genuinely started over.
/// </para>
/// </remarks>
public async Task<PendingOperation?> ReviseAsync(
long sequence,
SyncOperation operation,
int? expectedVersion,
EncryptedPayload? payload,
SyncPlaintextFields? fields,
StoredAncestor? ancestor,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<OutboxRow>()
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
return null;
}
row.OperationId = Guid.CreateVersion7();
row.Operation = operation;
row.ExpectedVersion = expectedVersion;
row.LastError = null;
row.IsParked = false;
SetPayload(row, row.EntityType, row.EntityId, payload, fields);
SetAncestor(row, row.EntityType, row.EntityId, ancestor);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return ToPending(row);
}
/// <summary>Reads the next operations to push, oldest first, skipping parked ones.</summary>
public async Task<IReadOnlyList<PendingOperation>> TakeAsync(
Guid vaultId,
int limit,
CancellationToken cancellationToken)
{
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set<OutboxRow>()
.AsNoTracking()
.Where(r => r.VaultId == vaultId && !r.IsParked)
.OrderBy(r => r.Sequence)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToPending)];
}
/// <summary>Reads the operation pending for one item, if any.</summary>
public async Task<PendingOperation?> FindAsync(
Guid vaultId,
SyncEntityType entityType,
Guid entityId,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await FindRowAsync(context, vaultId, entityType, entityId, cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToPending(row);
}
/// <summary>
/// Reads every pending operation for a vault, parked ones included.
/// </summary>
/// <remarks>
/// What the interface needs, as opposed to what the pusher needs. A parked change is still the
/// user's current intent for that item and must be what they see; hiding it because the server
/// refused it would show them the old values and look like their edit was lost.
/// </remarks>
public async Task<IReadOnlyList<PendingOperation>> ListAllAsync(
Guid vaultId,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set<OutboxRow>()
.AsNoTracking()
.Where(r => r.VaultId == vaultId)
.OrderBy(r => r.Sequence)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToPending)];
}
/// <summary>Reads operations the server refused, which need a person.</summary>
public async Task<IReadOnlyList<PendingOperation>> ListParkedAsync(
Guid vaultId,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set<OutboxRow>()
.AsNoTracking()
.Where(r => r.VaultId == vaultId && r.IsParked)
.OrderBy(r => r.Sequence)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToPending)];
}
/// <summary>Records that an operation has been sent, so a repeated failure can be noticed.</summary>
public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) =>
UpdateAsync(sequence, row => row.Attempts++, cancellationToken);
/// <summary>Removes an operation the server accepted.</summary>
public async Task<bool> CompleteAsync(long sequence, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var removed = await context.Set<OutboxRow>()
.Where(r => r.Sequence == sequence)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return removed > 0;
}
/// <summary>
/// Stops retrying an operation and records why.
/// </summary>
/// <remarks>
/// For the answers that will not change on retry — the server calling an operation structurally
/// invalid, or the caller no longer having permission. Retrying either would spin forever and, far
/// worse, would block every change queued behind it in a vault the user can still write to.
/// </remarks>
public Task ParkAsync(long sequence, string reason, CancellationToken cancellationToken) =>
UpdateAsync(
sequence,
row =>
{
row.IsParked = true;
row.LastError = reason;
},
cancellationToken);
/// <summary>Records a transient failure without parking the operation.</summary>
public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) =>
UpdateAsync(sequence, row => row.LastError = reason, cancellationToken);
private static Task<OutboxRow?> FindRowAsync(
ClientCacheContext context,
Guid vaultId,
SyncEntityType entityType,
Guid entityId,
CancellationToken cancellationToken) =>
context.Set<OutboxRow>()
.SingleOrDefaultAsync(
r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
cancellationToken);
private static void Validate(QueuedChange change)
{
if (change.Operation == SyncOperation.Upsert && change.Payload is null)
{
throw new ArgumentException("An upsert requires a payload.", nameof(change));
}
if (change.Operation == SyncOperation.Unspecified)
{
throw new ArgumentException("An operation is required.", nameof(change));
}
if (change.EntityId == Guid.Empty)
{
throw new ArgumentException("An entity id is required.", nameof(change));
}
}
private async Task UpdateAsync(
long sequence,
Action<OutboxRow> mutate,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<OutboxRow>()
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
return;
}
mutate(row);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
private void SetPayload(
OutboxRow row,
SyncEntityType entityType,
Guid entityId,
EncryptedPayload? payload,
SyncPlaintextFields? fields)
{
row.Payload = payload?.Envelope;
row.WrappedDataKey = payload?.WrappedDataKey;
row.DataKeyId = payload?.DataKeyId;
row.KeyGeneration = payload?.KeyGeneration ?? 0;
row.AadVersion = payload?.AadVersion ?? 0;
row.ProtectedFields = Seal(entityType, entityId, fields);
}
private void SetAncestor(
OutboxRow row,
SyncEntityType entityType,
Guid entityId,
StoredAncestor? ancestor)
{
row.AncestorVersion = ancestor?.Version;
row.AncestorPayload = ancestor?.Payload.Envelope;
row.AncestorWrappedDataKey = ancestor?.Payload.WrappedDataKey;
row.AncestorDataKeyId = ancestor?.Payload.DataKeyId;
row.AncestorKeyGeneration = ancestor?.Payload.KeyGeneration;
row.AncestorAadVersion = ancestor?.Payload.AadVersion;
row.AncestorProtectedFields = Seal(entityType, entityId, ancestor?.Fields);
}
private byte[]? Seal(SyncEntityType entityType, Guid entityId, SyncPlaintextFields? fields) =>
fields is null
? null
: protector.Protect(
AadResourceTypes.For(entityType), entityId, PlaintextFieldsCodec.Encode(fields));
private SyncPlaintextFields? Open(SyncEntityType entityType, Guid entityId, byte[]? sealedFields)
{
if (sealedFields is null)
{
return null;
}
var plaintext = protector.TryUnprotect(
AadResourceTypes.For(entityType), entityId, sealedFields);
return plaintext is null ? null : PlaintextFieldsCodec.TryDecode(plaintext);
}
private PendingOperation ToPending(OutboxRow row)
{
var ancestorPayload = CacheMapping.ToAncestorPayload(row);
var ancestor = ancestorPayload is null || row.AncestorVersion is null
? null
: new StoredAncestor(
row.AncestorVersion.Value,
ancestorPayload,
Open(row.EntityType, row.EntityId, row.AncestorProtectedFields));
return new PendingOperation(
row.Sequence,
row.OperationId,
row.VaultId,
row.EntityType,
row.EntityId,
row.Operation,
row.ExpectedVersion,
CacheMapping.ToPayload(
row.Payload, row.WrappedDataKey, row.DataKeyId, row.KeyGeneration, row.AadVersion),
Open(row.EntityType, row.EntityId, row.ProtectedFields),
ancestor,
row.QueuedAtUtc,
row.Attempts,
row.LastError,
row.IsParked);
}
}
+183
View File
@@ -0,0 +1,183 @@
using DodoSSH.Contracts;
namespace DodoSSH.Client.Storage;
/// <summary>Why a conflict record exists.</summary>
/// <remarks>
/// Persisted, so append only. These are the vocabulary the UI reasons about: each one implies a
/// different remedy, which is why they are distinguished rather than collapsed into "conflict".
/// </remarks>
public enum ConflictKind
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Both sides changed a field. One value survived; the other is in the detail.</summary>
FieldOverridden = 1,
/// <summary>This machine deleted an item that someone else edited. The edit won.</summary>
LocalDeleteOverridden = 2,
/// <summary>
/// Someone else deleted an item this machine had edited. The local content was preserved under a
/// new id rather than being lost to the tombstone.
/// </summary>
RemoteDeleteResurrected = 3,
/// <summary>
/// A payload failed its authentication tag or its schema. Either a client bug or a server that
/// handed back the wrong bytes; both need a human.
/// </summary>
Undecryptable = 4,
/// <summary>Written by a newer client than this one, so it is readable but not editable.</summary>
TooNewToEdit = 5,
/// <summary>The server refused the operation outright. Retrying will not help.</summary>
Rejected = 6,
}
/// <summary>What an offline unlock needs.</summary>
/// <param name="ServerUrl">The server this cache belongs to.</param>
/// <param name="UserId">The user, which every AAD in the bundle wrap binds to.</param>
/// <param name="Issuer">OIDC issuer.</param>
/// <param name="Subject">OIDC subject.</param>
/// <param name="Email">Email, for display.</param>
/// <param name="DisplayName">Display name, for display.</param>
/// <param name="KeyGeneration">Identity key generation.</param>
/// <param name="WrappedPrivateKey">The secret bundle, wrapped under the passphrase-derived key.</param>
/// <param name="KdfParameters">Parameters needed to re-derive that key.</param>
/// <param name="UpdatedAt">When this was last refreshed from the server.</param>
public sealed record StoredUnlockMaterial(
string ServerUrl,
Guid UserId,
string Issuer,
string Subject,
string? Email,
string? DisplayName,
uint KeyGeneration,
byte[] WrappedPrivateKey,
KdfParameters KdfParameters,
DateTimeOffset UpdatedAt);
/// <summary>A cached vault and the grant that opens it.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Display name.</param>
/// <param name="IsPersonal">Whether this is the user's personal vault.</param>
/// <param name="TeamId">Owning team, for a team vault.</param>
/// <param name="KeyGeneration">Current key generation.</param>
/// <param name="Permissions">Effective permissions, as a flags value.</param>
/// <param name="WrappedVaultKey">The vault key sealed to this user. Null while awaiting re-wrap.</param>
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
public sealed record StoredVault(
Guid VaultId,
string Name,
bool IsPersonal,
Guid? TeamId,
uint KeyGeneration,
int Permissions,
byte[]? WrappedVaultKey,
bool RekeyRequired);
/// <summary>The last item state the server confirmed.</summary>
/// <param name="VaultId">Owning vault.</param>
/// <param name="EntityType">Kind of item.</param>
/// <param name="EntityId">The item.</param>
/// <param name="Version">Server-assigned version — what a push must expect.</param>
/// <param name="ChangeSequence">Position in the vault's change log.</param>
/// <param name="Payload">Ciphertext as the server returned it. Null for a tombstone.</param>
/// <param name="Fields">The plaintext columns. Null for a tombstone.</param>
/// <param name="IsDeleted">Whether this is a tombstone.</param>
/// <param name="UpdatedAt">When the change was recorded.</param>
public sealed record StoredItem(
Guid VaultId,
SyncEntityType EntityType,
Guid EntityId,
int Version,
long ChangeSequence,
EncryptedPayload? Payload,
SyncPlaintextFields? Fields,
bool IsDeleted,
DateTimeOffset UpdatedAt);
/// <summary>The item version a pending local edit branched from.</summary>
/// <remarks>
/// Without this a conflict can only be arbitrated, not merged. It is retained verbatim, including the
/// key generation and data key id, because those are part of the payload's AAD and the ancestor cannot
/// be decrypted without them.
/// </remarks>
/// <param name="Version">The version this edit was made against.</param>
/// <param name="Payload">That version's ciphertext.</param>
/// <param name="Fields">That version's plaintext columns.</param>
public sealed record StoredAncestor(
int Version,
EncryptedPayload Payload,
SyncPlaintextFields? Fields);
/// <summary>A local change waiting to be pushed.</summary>
/// <param name="Sequence">Local ordering. Assigned by the store; ignored on queue.</param>
/// <param name="OperationId">The server's idempotency key for this operation.</param>
/// <param name="VaultId">Owning vault.</param>
/// <param name="EntityType">Kind of item.</param>
/// <param name="EntityId">The item.</param>
/// <param name="Operation">Upsert or delete.</param>
/// <param name="ExpectedVersion">The version the client believes the server holds; null to create.</param>
/// <param name="Payload">Ciphertext to store. Null for a delete.</param>
/// <param name="Fields">Plaintext columns. Null for a delete.</param>
/// <param name="Ancestor">The version this branched from. Null when creating.</param>
/// <param name="QueuedAt">When the user made the change.</param>
/// <param name="Attempts">How many times this has been dispatched.</param>
/// <param name="LastError">Why it last failed.</param>
/// <param name="IsParked">Whether it has been abandoned pending user action.</param>
public sealed record PendingOperation(
long Sequence,
Guid OperationId,
Guid VaultId,
SyncEntityType EntityType,
Guid EntityId,
SyncOperation Operation,
int? ExpectedVersion,
EncryptedPayload? Payload,
SyncPlaintextFields? Fields,
StoredAncestor? Ancestor,
DateTimeOffset QueuedAt,
int Attempts = 0,
string? LastError = null,
bool IsParked = false);
/// <summary>Where a vault's pull has reached.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Cursor">The last server-issued cursor. Never constructed by a client.</param>
/// <param name="KeyGeneration">The generation the server last reported.</param>
/// <param name="LastPulledAt">When the last pull completed.</param>
/// <param name="LastPushedAt">When the last push completed.</param>
/// <param name="ServerTimeSkewMs">Observed clock difference, recorded and never acted on.</param>
public sealed record StoredSyncState(
Guid VaultId,
string? Cursor,
uint KeyGeneration,
DateTimeOffset? LastPulledAt = null,
DateTimeOffset? LastPushedAt = null,
long ServerTimeSkewMs = 0);
/// <summary>Something the merge overrode, or an item that could not be processed.</summary>
/// <param name="Id">This record's own id, which its sealed detail is bound to.</param>
/// <param name="VaultId">Owning vault.</param>
/// <param name="EntityType">Kind of item.</param>
/// <param name="EntityId">The item.</param>
/// <param name="Kind">What happened.</param>
/// <param name="Detail">
/// The discarded values, in plaintext across this boundary and sealed at rest. Opaque to the store:
/// its shape belongs to the sync layer, which owns what a conflict means.
/// </param>
/// <param name="DetectedAt">When it was noticed.</param>
/// <param name="Acknowledged">Whether the user has dealt with it.</param>
public sealed record StoredConflict(
Guid Id,
Guid VaultId,
SyncEntityType EntityType,
Guid EntityId,
ConflictKind Kind,
byte[] Detail,
DateTimeOffset DetectedAt,
bool Acknowledged = false);
@@ -0,0 +1,110 @@
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>
/// Where each vault's pull has reached.
/// </summary>
/// <remarks>
/// <para>
/// The cursor is stored exactly as the server issued it and is never parsed, constructed or adjusted.
/// It is opaque and integrity-tagged for a reason: a client that could synthesise one could ask to
/// resume from a position the server never granted, and a tampered cursor is rejected rather than
/// silently mis-serving a range.
/// </para>
/// <para>
/// A null cursor means "from the beginning", which is also the recovery path for a cache that has been
/// discarded or that failed to decrypt. Re-pulling from nothing is always safe; guessing a position is
/// not.
/// </para>
/// </remarks>
public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> contexts)
{
/// <summary>
/// Reads a vault's position, or a fresh one starting from the beginning.
/// </summary>
/// <remarks>
/// Never returns null. An unknown vault is not an error — it is a vault this client has not synced
/// yet — and a caller forced to handle a null here would most likely handle it by starting from the
/// beginning anyway.
/// </remarks>
public async Task<StoredSyncState> ReadAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<SyncStateRow>()
.AsNoTracking()
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
.ConfigureAwait(false);
return row is null
? new StoredSyncState(vaultId, Cursor: null, KeyGeneration: 0)
: new StoredSyncState(
row.VaultId,
row.Cursor,
row.KeyGeneration,
row.LastPulledAtUtc,
row.LastPushedAtUtc,
row.ServerTimeSkewMs);
}
/// <summary>Records a vault's position.</summary>
public async Task SaveAsync(StoredSyncState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<SyncStateRow>()
.SingleOrDefaultAsync(r => r.VaultId == state.VaultId, cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new SyncStateRow { VaultId = state.VaultId };
context.Add(row);
}
row.Cursor = state.Cursor;
row.KeyGeneration = state.KeyGeneration;
row.LastPulledAtUtc = state.LastPulledAt;
row.LastPushedAtUtc = state.LastPushedAt;
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Forgets a vault's position so the next pull starts over.
/// </summary>
/// <remarks>
/// <para>
/// The remedy when the cache cannot be trusted — a key generation the client has no grant for, or
/// rows that will not decrypt. A full re-pull is cheap next to the alternative of reasoning about
/// which half of the cache is still valid.
/// </para>
/// <para>
/// <b>The outbox is deliberately not cleared.</b> Those rows are the only copy of changes the user
/// made and the server has not accepted; discarding them here would turn a recoverable cache
/// problem into lost work. They re-push against the re-pulled state, conflicting and merging where
/// they must.
/// </para>
/// </remarks>
public async Task ResetAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
await context.Set<SyncStateRow>()
.Where(r => r.VaultId == vaultId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.Set<CachedItemRow>()
.Where(r => r.VaultId == vaultId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
}
+137
View File
@@ -0,0 +1,137 @@
using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>
/// Thrown when the cache belongs to a different account than the one signing in.
/// </summary>
/// <remarks>
/// Loud on purpose. Silently adopting the cache would mix one user's items into another's vault list
/// and, worse, would offer an unlock prompt whose passphrase can never work.
/// </remarks>
public sealed class CacheIdentityMismatchException : InvalidOperationException
{
/// <summary>Creates the exception.</summary>
public CacheIdentityMismatchException(string message)
: base(message)
{
}
/// <summary>Creates the exception.</summary>
public CacheIdentityMismatchException()
: base("This cache belongs to a different account.")
{
}
/// <summary>Creates the exception.</summary>
public CacheIdentityMismatchException(string message, Exception innerException)
: base(message, innerException)
{
}
}
/// <summary>
/// The material an offline unlock needs.
/// </summary>
/// <remarks>
/// <para>
/// This store is the reason the client works on a plane. The Argon2id salt and the wrapped secret
/// bundle are cached the moment the server hands them over, so deriving the master key and opening the
/// bundle need no network at all. Fetching either at unlock time would make an offline launch
/// impossible, which is the most common moment a user actually needs their vault.
/// </para>
/// <para>
/// Neither value is a secret. The salt is public by construction and the bundle is ciphertext whose key
/// exists only in the user's head. The master key itself is never written here or anywhere else.
/// </para>
/// </remarks>
public sealed class UnlockStore(IDbContextFactory<ClientCacheContext> contexts, TimeProvider clock)
{
/// <summary>Reads the cached material, or null when this cache has never been enrolled.</summary>
public async Task<StoredUnlockMaterial?> ReadAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<UnlockMaterialRow>()
.AsNoTracking()
.SingleOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToStored(row);
}
/// <summary>
/// Writes the material, replacing what is there.
/// </summary>
/// <exception cref="CacheIdentityMismatchException">
/// The cache already holds a different user. One cache file is one account; see
/// <see cref="UnlockMaterialRow"/> for why multiple accounts are not half-supported here.
/// </exception>
public async Task SaveAsync(StoredUnlockMaterial material, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(material);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<UnlockMaterialRow>()
.SingleOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new UnlockMaterialRow();
context.Add(row);
}
else if (row.UserId != material.UserId)
{
throw new CacheIdentityMismatchException(
$"This cache holds user {row.UserId}; refusing to overwrite it with {material.UserId}.");
}
Apply(row, material, clock.GetUtcNow());
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
private static void Apply(
UnlockMaterialRow row,
StoredUnlockMaterial material,
DateTimeOffset now)
{
row.ServerUrl = material.ServerUrl;
row.UserId = material.UserId;
row.Issuer = material.Issuer;
row.Subject = material.Subject;
row.Email = material.Email;
row.DisplayName = material.DisplayName;
row.KeyGeneration = material.KeyGeneration;
row.WrappedPrivateKey = material.WrappedPrivateKey;
row.KdfAlgorithm = material.KdfParameters.Algorithm;
row.KdfSalt = material.KdfParameters.Salt;
row.KdfMemoryKibibytes = material.KdfParameters.MemoryKibibytes;
row.KdfPasses = material.KdfParameters.Passes;
row.KdfParallelism = material.KdfParameters.Parallelism;
row.UpdatedAtUtc = now;
}
private static StoredUnlockMaterial ToStored(UnlockMaterialRow row) =>
new(
row.ServerUrl,
row.UserId,
row.Issuer,
row.Subject,
row.Email,
row.DisplayName,
row.KeyGeneration,
row.WrappedPrivateKey,
new KdfParameters(
row.KdfAlgorithm,
row.KdfSalt,
row.KdfMemoryKibibytes,
row.KdfPasses,
row.KdfParallelism),
row.UpdatedAtUtc);
}
+113
View File
@@ -0,0 +1,113 @@
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>
/// The vaults this user can reach, and the grants that open them.
/// </summary>
/// <remarks>
/// Cached for the same reason as the unlock material: without the wrapped vault key on disk, an offline
/// launch could unlock the identity bundle and still not decrypt a single item. Every value here is
/// either public metadata or ciphertext.
/// </remarks>
public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, TimeProvider clock)
{
/// <summary>Reads every known vault.</summary>
public async Task<IReadOnlyList<StoredVault>> ListAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set<CachedVaultRow>()
.AsNoTracking()
.OrderByDescending(row => row.IsPersonal)
.ThenBy(row => row.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToStored)];
}
/// <summary>Reads one vault.</summary>
public async Task<StoredVault?> FindAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<CachedVaultRow>()
.AsNoTracking()
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToStored(row);
}
/// <summary>
/// Replaces the cached vault list with what the server reported.
/// </summary>
/// <remarks>
/// <para>
/// Vaults absent from the list are removed, because losing access to a vault is exactly what that
/// absence means and a stale row would offer the user a vault they can no longer sync.
/// </para>
/// <para>
/// Their <em>items</em> are a separate matter and are not touched here. Removing a member does not
/// retroactively erase what they already hold — that is not achievable, which is why offboarding
/// means rotating the SSH credential rather than revoking a grant. Deleting the local rows here
/// would only make the client pretend otherwise.
/// </para>
/// </remarks>
public async Task ReplaceAllAsync(
IReadOnlyList<StoredVault> vaults,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(vaults);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var existing = await context.Set<CachedVaultRow>()
.ToDictionaryAsync(row => row.VaultId, cancellationToken)
.ConfigureAwait(false);
var now = clock.GetUtcNow();
foreach (var vault in vaults)
{
if (!existing.Remove(vault.VaultId, out var row))
{
row = new CachedVaultRow { VaultId = vault.VaultId };
context.Add(row);
}
Apply(row, vault, now);
}
context.RemoveRange(existing.Values);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
{
row.Name = vault.Name;
row.IsPersonal = vault.IsPersonal;
row.TeamId = vault.TeamId;
row.KeyGeneration = vault.KeyGeneration;
row.Permissions = vault.Permissions;
row.WrappedVaultKey = vault.WrappedVaultKey;
row.RekeyRequired = vault.RekeyRequired;
row.UpdatedAtUtc = now;
}
private static StoredVault ToStored(CachedVaultRow row) =>
new(
row.VaultId,
row.Name,
row.IsPersonal,
row.TeamId,
row.KeyGeneration,
row.Permissions,
row.WrappedVaultKey,
row.RekeyRequired);
}
@@ -0,0 +1,400 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"EFCore.NamingConventions": {
"type": "Direct",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
}
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Microsoft.EntityFrameworkCore.Design": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "BsvxiKcy8k4/ijAPitmwKG1mlVsdC2lQtFLP28K2N8PlsGYbqPFOyfJ7p2kWil3gM6xXgQGf8Hz/pJB8ej+Dug==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.Build.Framework": "18.0.2",
"Microsoft.CodeAnalysis.CSharp": "5.0.0",
"Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0",
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0",
"Microsoft.EntityFrameworkCore.Relational": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"Mono.TextTemplating": "3.0.0",
"Newtonsoft.Json": "13.0.3"
}
},
"Microsoft.EntityFrameworkCore.Sqlite": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
"SQLitePCLRaw.core": "2.1.11"
}
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"Microsoft.Build.Framework": {
"type": "Transitive",
"resolved": "18.0.2",
"contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA=="
},
"Microsoft.CodeAnalysis.Analyzers": {
"type": "Transitive",
"resolved": "3.11.0",
"contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg=="
},
"Microsoft.CodeAnalysis.Common": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==",
"dependencies": {
"Microsoft.CodeAnalysis.Analyzers": "3.11.0"
}
},
"Microsoft.CodeAnalysis.CSharp": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==",
"dependencies": {
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
"Microsoft.CodeAnalysis.Common": "[5.0.0]"
}
},
"Microsoft.CodeAnalysis.CSharp.Workspaces": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
"Microsoft.CodeAnalysis.CSharp": "[5.0.0]",
"Microsoft.CodeAnalysis.Common": "[5.0.0]",
"Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
"System.Composition": "9.0.0"
}
},
"Microsoft.CodeAnalysis.Workspaces.Common": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
"Microsoft.CodeAnalysis.Common": "[5.0.0]",
"System.Composition": "9.0.0"
}
},
"Microsoft.CodeAnalysis.Workspaces.MSBuild": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.Build.Framework": "17.11.31",
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
"Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
"Microsoft.Extensions.DependencyInjection": "9.0.0",
"Microsoft.Extensions.Logging": "9.0.0",
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
"Microsoft.Extensions.Options": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0",
"Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
"Newtonsoft.Json": "13.0.3",
"System.Composition": "9.0.0"
}
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.EntityFrameworkCore.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
},
"Microsoft.EntityFrameworkCore.Analyzers": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
},
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "10.0.10",
"Microsoft.EntityFrameworkCore.Relational": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
"Microsoft.Extensions.Options": "10.0.10",
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.10",
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
"Microsoft.Extensions.Options": "10.0.10"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
},
"Microsoft.VisualStudio.SolutionPersistence": {
"type": "Transitive",
"resolved": "1.0.52",
"contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w=="
},
"Mono.TextTemplating": {
"type": "Transitive",
"resolved": "3.0.0",
"contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==",
"dependencies": {
"System.CodeDom": "6.0.0"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
},
"System.Composition": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==",
"dependencies": {
"System.Composition.AttributedModel": "9.0.0",
"System.Composition.Convention": "9.0.0",
"System.Composition.Hosting": "9.0.0",
"System.Composition.Runtime": "9.0.0",
"System.Composition.TypedParts": "9.0.0"
}
},
"System.Composition.AttributedModel": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA=="
},
"System.Composition.Convention": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==",
"dependencies": {
"System.Composition.AttributedModel": "9.0.0"
}
},
"System.Composition.Hosting": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==",
"dependencies": {
"System.Composition.Runtime": "9.0.0"
}
},
"System.Composition.Runtime": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA=="
},
"System.Composition.TypedParts": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==",
"dependencies": {
"System.Composition.AttributedModel": "9.0.0",
"System.Composition.Hosting": "9.0.0",
"System.Composition.Runtime": "9.0.0"
}
},
"dodossh.contracts": {
"type": "Project"
},
"dodossh.crypto": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )"
}
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"Microsoft.EntityFrameworkCore": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
}
},
"SQLitePCLRaw.core": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.12"
}
}
}
}
}