diff --git a/.editorconfig b/.editorconfig
index 60979ca..b7bc01e 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -139,11 +139,16 @@ dotnet_diagnostic.CA1034.severity = none
# convention exists so callers can spot awaitables; a test method has no callers.
dotnet_diagnostic.IDE1006.severity = none
-[src/DodoSSH.Infrastructure/Migrations/*.cs]
+# Matches every project's Migrations folder, not just Infrastructure's: the client's local cache
+# is migrated too. A glob rather than one block per project, so a third one is not a build break
+# for whoever adds it.
+[src/*/Migrations/*.cs]
# EF Core generates these; do not lint or format them.
generated_code = true
dotnet_analyzer_diagnostic.severity = none
dotnet_diagnostic.IDE0055.severity = none
+# IDE style rules are not covered by dotnet_analyzer_diagnostic above and have to be named.
+dotnet_diagnostic.IDE0161.severity = none
[*.{g,g.i,generated,designer}.cs]
# Source-generator output. In particular the System.Text.Json generator emits a public
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e243eba..adfe7d5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -25,6 +25,22 @@
itself moves off 2.0.0.
-->
+
+
+
+
+
+
@@ -45,6 +61,13 @@
HasColumnName in every IEntityTypeConfiguration: more code, zero risk.
-->
+
+
diff --git a/DodoSSH.slnx b/DodoSSH.slnx
index 5b125b7..60c1c10 100644
--- a/DodoSSH.slnx
+++ b/DodoSSH.slnx
@@ -19,7 +19,10 @@
+
+
+
@@ -27,7 +30,10 @@
+
+
+
diff --git a/README.md b/README.md
index f5cae30..02023fa 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,10 @@ src/
DodoSSH.Infrastructure DbContext, configurations, migrations
DodoSSH.Api the server
DodoSSH.Client.Auth OIDC code+PKCE on a loopback redirect, and the key binding
+ DodoSSH.Client.Api the typed server client, and client-side enrollment
+ DodoSSH.Client.Domain the decrypted item model and the three-way merge — no I/O at all
+ DodoSSH.Client.Storage the local cache: ciphertext mirror, outbox, offline unlock material
+ DodoSSH.Client.Sync the pull/apply/push loop and the conflict policy
DodoSSH.Client.Ssh connections, PTY shells, host key trust
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit
@@ -100,10 +104,11 @@ off-Windows.
*Server done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and
enrollment with the identity-provider key binding.
*Client done:* the key hierarchy, the OIDC flow with the key binding, SSH connections with host key
- trust, the terminal data plane, and an Avalonia shell whose terminal works end to end against a real
- `sshd`.
- *Remaining:* the encrypted local cache and the sync client, which are what let the app read hosts
- from the vault instead of a form. The client currently connects to a host you type in.
+ trust, the terminal data plane, an Avalonia shell whose terminal works end to end against a real
+ `sshd`, and the encrypted local cache with the sync client — hosts, offline unlock, an outbox and a
+ field-level three-way merge, with the conflict matrix green.
+ *Remaining:* wiring the shell to the vault, so the host list comes from `HostRepository` rather than
+ from the form the window still shows.
- **M2 — full personal vault**, robust sync, relay.
- **M3 — teams**, sharing, ACLs.
- **M4 — hardening and ops**, packaging, self-hosting guide.
diff --git a/docs/crypto.md b/docs/crypto.md
index 08b1c85..e5af0da 100644
--- a/docs/crypto.md
+++ b/docs/crypto.md
@@ -230,12 +230,26 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
| 3 | `ItemDataKey` | a data key wrapped under a vault key |
| 4 | `ItemPayload` | item plaintext under its data key |
| 5 | `ItemMetadata` | encrypted host metadata under its data key |
-| 6 | `LocalCache` | a client's on-disk cache record |
+| 6 | `LocalCache` | a client's on-disk cache record, bound to its own row |
+
+> **Changed 2026-07-29:** `LocalCache` binds `resourceType` and the record's own id rather
+> than the user id. The user is already bound by the key — `LocalCacheKey` derives from that
+> user's MK — so binding it again in the AAD constrained nothing, and left cache records
+> interchangeable between rows of the same cache. For a plaintext column such as a
+> relay-enabled host's address, swapping two rows would aim one host's connection at
+> another's. No cache has been written, so nothing needs migrating.
### 4.3 `resourceType`
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
-`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey.
+`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
+`13` HostCredential.
+
+> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
+> `HostCredential` as syncable since the contract was frozen, but this table had no value for
+> either — so an association row's payload had no resource type to bind to, and the first
+> implementation to need one would have had to invent a value or reuse a neighbour's. Append
+> only, and no such item has been stored.
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
diff --git a/docs/platform-flags.md b/docs/platform-flags.md
index 42035f0..9f93802 100644
--- a/docs/platform-flags.md
+++ b/docs/platform-flags.md
@@ -103,6 +103,29 @@ minimal desktop or inside a Flatpak sandbox — where the portal is the correct
sign-in silently does nothing on Linux, this is the first thing to check. `IBrowserLauncher` exists
so a platform-specific opener can be substituted without touching the flow.
+## Local cache
+
+**The cache file has no location yet.** `ClientCacheFactory.ForFile` takes a full path and the
+application does not yet choose one, because nothing wires the cache into the shell so far. When it
+does, the path must be per-OS — `%LOCALAPPDATA%` on Windows, `~/Library/Application Support` on macOS,
+`$XDG_DATA_HOME` or `~/.local/share` on Linux — and it must **not** land in a directory that syncs to
+a cloud drive. Two machines writing one SQLite file through a file-sync client corrupts it, and the
+whole point of the outbox is that each machine has its own. `Environment.SpecialFolder.LocalApplicationData`
+maps correctly on all three, but on Linux it ignores `XDG_DATA_HOME` and returns `~/.local/share`
+unconditionally. *Unverified off Windows.*
+
+**SQLite timestamps are stored as integers, deliberately.** EF's default `DateTimeOffset` mapping for
+SQLite is a text form it then refuses to order or compare, so any query that sorts or filters by time
+throws at execution rather than at model build. `UnixMillisecondsConverter` is applied as a convention
+so a timestamp added later cannot be the one left unconverted. This is provider behaviour, not
+platform behaviour, but it cost a debugging session and will again if the converter is removed.
+
+**No SQLCipher, on any platform.** The rows are already ciphertext from the server, so an encrypted
+database file would protect bytes that are protected already at the cost of a native dependency and a
+licence obligation — and `bundle_e_sqlcipher` was deprecated in SQLitePCLRaw 3.0. The consequence to
+be honest about: the cache offers no protection against another process running as the same user. See
+`LocalCacheProtector` for what it does and does not defend against.
+
## Build and CI
**Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI.
diff --git a/src/DodoSSH.Api/Features/Sync/SyncService.cs b/src/DodoSSH.Api/Features/Sync/SyncService.cs
index 8f33a92..d4d79fa 100644
--- a/src/DodoSSH.Api/Features/Sync/SyncService.cs
+++ b/src/DodoSSH.Api/Features/Sync/SyncService.cs
@@ -264,6 +264,11 @@ internal sealed class SyncService(
return Invalid(operation, "An upsert requires a payload.");
}
+ if (!ValidatePayload(operation.Payload, out var payloadError))
+ {
+ return Invalid(operation, payloadError);
+ }
+
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
if (!ValidateRelayFields(fields, out var relayError))
@@ -392,6 +397,8 @@ internal sealed class SyncService(
DateTimeOffset now)
{
host.Payload = payload.Envelope;
+ host.DataKeyWrap = payload.WrappedDataKey;
+ host.ContentKeyId = payload.DataKeyId;
host.KeyGeneration = (int)payload.KeyGeneration;
host.PayloadAadVersion = payload.AadVersion;
host.RelayEnabled = fields.RelayEnabled;
@@ -403,6 +410,41 @@ internal sealed class SyncService(
host.UpdatedByUserId = actorUserId;
}
+ ///
+ /// Rejects a payload missing its data key.
+ ///
+ ///
+ /// The server cannot read any of these bytes, so this is a structural check and nothing more.
+ /// It is still worth making: docs/crypto.md §3 requires a per-item data key, the payload's AAD
+ /// binds , and a row stored without a wrap is a row no
+ /// client will ever be able to open. Better to refuse it here — where the client is told which
+ /// operation was wrong — than to store an item that silently reads as corrupt forever.
+ ///
+ private static bool ValidatePayload(EncryptedPayload payload, out string error)
+ {
+ error = string.Empty;
+
+ if (payload.Envelope.Length == 0)
+ {
+ error = "A payload envelope cannot be empty.";
+ return false;
+ }
+
+ if (payload.WrappedDataKey.Length == 0)
+ {
+ error = "A payload requires its data key, wrapped under the vault key.";
+ return false;
+ }
+
+ if (payload.DataKeyId == Guid.Empty)
+ {
+ error = "A payload requires a data key identifier; it is part of the payload's AAD.";
+ return false;
+ }
+
+ return true;
+ }
+
///
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a
/// constraint violation surfacing as a 500.
@@ -566,6 +608,11 @@ internal sealed class SyncService(
? null
: new EncryptedPayload(
host.Payload,
+ // Non-null for every row a push can create: ValidatePayload refuses an
+ // operation without them. The columns stay nullable because they are also
+ // the seam for M5's per-item grants.
+ host.DataKeyWrap ?? [],
+ host.ContentKeyId ?? Guid.Empty,
(uint)host.KeyGeneration,
(byte)host.PayloadAadVersion),
PlaintextFields: isDelete || host is null
diff --git a/src/DodoSSH.Client.Api/DodoSshApiClient.cs b/src/DodoSSH.Client.Api/DodoSshApiClient.cs
index f63e6ec..54fdb9c 100644
--- a/src/DodoSSH.Client.Api/DodoSshApiClient.cs
+++ b/src/DodoSSH.Client.Api/DodoSshApiClient.cs
@@ -18,6 +18,31 @@ public interface IAccessTokenProvider
ValueTask GetAccessTokenAsync(CancellationToken cancellationToken);
}
+///
+/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
+///
+///
+/// The sync engine's job is a conflict-resolution policy, and testing a policy against a stubbed
+/// transport only proves that the right bytes were sent. Behind this interface the suite runs an
+/// in-memory server that enforces the real version checks, assigns real change sequences and issues
+/// real cursors — so a test can assert what happens when two clients edit one host, which is the
+/// question that actually matters.
+///
+public interface ISyncApi
+{
+ /// Reads vault changes after a cursor.
+ Task SyncPullAsync(
+ Guid vaultId,
+ SyncPullRequest request,
+ CancellationToken cancellationToken);
+
+ /// Applies a batch of vault changes.
+ Task SyncPushAsync(
+ Guid vaultId,
+ SyncPushRequest request,
+ CancellationToken cancellationToken);
+}
+
///
/// The typed client for one DodoSSH server.
///
@@ -33,7 +58,7 @@ public interface IAccessTokenProvider
/// Everything else carries a bearer token.
///
///
-public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
+public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) : ISyncApi
{
private const string MetaPath = "/api/v1/meta";
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
diff --git a/src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj b/src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj
new file mode 100644
index 0000000..9896214
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Domain/HostOptions.cs b/src/DodoSSH.Client.Domain/HostOptions.cs
new file mode 100644
index 0000000..3f57a34
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/HostOptions.cs
@@ -0,0 +1,221 @@
+using System.Collections;
+using System.Diagnostics.CodeAnalysis;
+
+namespace DodoSSH.Client.Domain;
+
+///
+/// One SSH configuration directive on a host.
+///
+///
+/// Equality treats the name case-insensitively, matching how SSH reads keywords. Without that, two
+/// clients that resolved the same merge could end up holding ServerAliveInterval and
+/// serveraliveinterval, compare their hosts as different, and push over each other forever
+/// while agreeing on every actual value.
+///
+/// Directive name, for example ServerAliveInterval.
+/// Directive value, verbatim and case-sensitive.
+public sealed record HostOption(string Name, string Value)
+{
+ /// Defines directive identity: SSH keywords are case-insensitive.
+ public static StringComparer NameComparer => StringComparer.OrdinalIgnoreCase;
+
+ ///
+ public bool Equals(HostOption? other) =>
+ other is not null
+ && NameComparer.Equals(Name, other.Name)
+ && string.Equals(Value, other.Value, StringComparison.Ordinal);
+
+ ///
+ public override int GetHashCode() =>
+ HashCode.Combine(NameComparer.GetHashCode(Name), Value.GetHashCode(StringComparison.Ordinal));
+}
+
+///
+/// A host's SSH directives: unique by name, held in name order.
+///
+///
+///
+/// Both invariants are load-bearing for the merge. Uniqueness gives every value a stable key, which
+/// is what lets two people add different directives to the same host and both survive — a
+/// whole-collection comparison would make that a conflict and discard one side. Name order makes the
+/// encoding deterministic, so re-encoding an unchanged host produces identical bytes and the sync
+/// engine does not push a spurious update on every pass.
+///
+///
+/// The cost, stated plainly: real ssh_config permits a directive to repeat, and for
+/// most keywords the first occurrence wins. That cannot be represented here. It is a deliberate M1
+/// limitation rather than an oversight — a repeated key has no merge key — and the import path must
+/// surface it rather than quietly keeping one of the duplicates.
+///
+///
+public sealed class HostOptions : IReadOnlyList, IEquatable
+{
+ private readonly HostOption[] items;
+ private readonly int hash;
+
+ private HostOptions(HostOption[] items)
+ {
+ this.items = items;
+ hash = ComputeHash(items);
+ }
+
+ /// No directives.
+ public static HostOptions Empty { get; } = new([]);
+
+ ///
+ public int Count => items.Length;
+
+ ///
+ public HostOption this[int index] => items[index];
+
+ ///
+ /// Builds a canonical collection, sorting by name.
+ ///
+ /// A name repeats, or a name is blank.
+ public static HostOptions Create(IEnumerable options)
+ {
+ if (!TryCreate(options, out var result, out var error))
+ {
+ throw new ArgumentException(error, nameof(options));
+ }
+
+ return result;
+ }
+
+ ///
+ /// Builds a canonical collection, reporting rather than throwing on bad input.
+ ///
+ ///
+ /// The non-throwing overload exists because these values arrive from two places neither of which
+ /// is trusted: a decrypted payload written by another client, and an imported
+ /// ssh_config. Neither should be able to raise an exception from inside a sync pass.
+ ///
+ public static bool TryCreate(
+ IEnumerable options,
+ [NotNullWhen(true)] out HostOptions? result,
+ [NotNullWhen(false)] out string? error)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ result = null;
+ var ordered = options.ToArray();
+
+ if (!Validate(ordered, out error))
+ {
+ return false;
+ }
+
+ Array.Sort(
+ ordered,
+ static (left, right) => HostOption.NameComparer.Compare(left.Name, right.Name));
+
+ result = ordered.Length == 0 ? Empty : new HostOptions(ordered);
+ return true;
+ }
+
+ /// Looks up a directive by name, case-insensitively as SSH treats keywords.
+ public bool TryGetValue(string name, [NotNullWhen(true)] out string? value)
+ {
+ foreach (var option in items)
+ {
+ if (HostOption.NameComparer.Equals(option.Name, name))
+ {
+ value = option.Value;
+ return true;
+ }
+ }
+
+ value = null;
+ return false;
+ }
+
+ ///
+ public bool Equals(HostOptions? other)
+ {
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ if (other is null || other.items.Length != items.Length || other.hash != hash)
+ {
+ return false;
+ }
+
+ return items.AsSpan().SequenceEqual(other.items);
+ }
+
+ ///
+ public override bool Equals(object? obj) => Equals(obj as HostOptions);
+
+ ///
+ public override int GetHashCode() => hash;
+
+ ///
+ public IEnumerator GetEnumerator() => ((IEnumerable)items).GetEnumerator();
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator() => items.GetEnumerator();
+
+ /// Contents equality, tolerating nulls on either side.
+ [SuppressMessage(
+ "Usage",
+ "CA2225:Operator overloads have named alternates",
+ Justification = "Equals(HostOptions) is the named alternate.")]
+ public static bool operator ==(HostOptions? left, HostOptions? right) =>
+ left is null ? right is null : left.Equals(right);
+
+ /// Contents inequality.
+ public static bool operator !=(HostOptions? left, HostOptions? right) => !(left == right);
+
+ /// Projects to a name-keyed map, for the per-directive merge.
+ internal Dictionary ToNameMap()
+ {
+ var map = new Dictionary(items.Length, HostOption.NameComparer);
+
+ foreach (var option in items)
+ {
+ map[option.Name] = option.Value;
+ }
+
+ return map;
+ }
+
+ private static bool Validate(HostOption[] ordered, [NotNullWhen(false)] out string? error)
+ {
+ foreach (var option in ordered)
+ {
+ if (option is null || string.IsNullOrWhiteSpace(option.Name))
+ {
+ error = "An SSH directive must have a name.";
+ return false;
+ }
+ }
+
+ var duplicate = ordered
+ .GroupBy(o => o.Name, HostOption.NameComparer)
+ .FirstOrDefault(g => g.Count() > 1);
+
+ if (duplicate is not null)
+ {
+ error = $"The directive '{duplicate.Key}' appears more than once; M1 requires unique names.";
+ return false;
+ }
+
+ error = null;
+ return true;
+ }
+
+ private static int ComputeHash(HostOption[] items)
+ {
+ var accumulator = new HashCode();
+ accumulator.Add(items.Length);
+
+ foreach (var option in items)
+ {
+ accumulator.Add(option);
+ }
+
+ return accumulator.ToHashCode();
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/HostSecret.cs b/src/DodoSSH.Client.Domain/HostSecret.cs
new file mode 100644
index 0000000..2c55b0a
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/HostSecret.cs
@@ -0,0 +1,116 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace DodoSSH.Client.Domain;
+
+///
+/// A host as the user sees it: everything the server never gets to read.
+///
+///
+///
+/// The whole of this record lives inside the item's encrypted payload. In particular there is no
+/// plaintext label anywhere in the system — access-control administration runs on the client, which
+/// can decrypt names, so the server never needs a searchable title.
+///
+///
+/// and are here and may additionally appear as
+/// plaintext columns on the server, but only for a host the user has opted into the relay. That is
+/// the one deliberate privacy concession in the design: the relay must resolve its target
+/// server-side or it becomes an authenticated open TCP proxy into the operator's own network. The
+/// copy in here is the authoritative one; the plaintext column is a derived duplicate the client
+/// supplies only when relay is enabled. See ADR 0004.
+///
+///
+/// Structural equality holds across every field, including the collections, which is what the merge
+/// relies on to tell "unchanged" from "changed to the same thing" from "changed differently".
+///
+///
+public sealed record HostSecret
+{
+ /// The default SSH port, used when a host does not say otherwise.
+ public const int DefaultPort = 22;
+
+ /// Display name. The only name this host has anywhere.
+ public required string Label { get; init; }
+
+ /// Hostname or address to connect to.
+ public required string Hostname { get; init; }
+
+ /// TCP port.
+ public int Port { get; init; } = DefaultPort;
+
+ /// Login user, when the host pins one.
+ public string? Username { get; init; }
+
+ /// Free-text notes.
+ public string? Notes { get; init; }
+
+ ///
+ /// The jump chain, nearest hop first, as host item ids.
+ ///
+ ///
+ /// Order is the meaning here, so this merges as a whole value rather than as a set: reordering a
+ /// chain changes which machine is reached through which, and a set union of two different chains
+ /// would produce a route neither user asked for.
+ ///
+ public JumpChain JumpHostIds { get; init; } = JumpChain.Empty;
+
+ /// SSH directives, unique by name.
+ public HostOptions Options { get; init; } = HostOptions.Empty;
+
+ ///
+ /// Whether this host may be dialled through the server relay.
+ ///
+ ///
+ ///
+ /// Lives here, inside the encrypted payload, rather than only in the plaintext columns the server
+ /// keeps. It has to: it is the flag that decides whether and
+ /// are copied out into those columns, and a setting the merge cannot see is a
+ /// setting two clients can silently disagree about — one of them re-exposing an address the other
+ /// had just withdrawn.
+ ///
+ ///
+ /// The plaintext copy is derived from this, in one place, so the address can only ever leave the
+ /// payload as a consequence of the user turning this on. See ADR 0004.
+ ///
+ ///
+ public bool RelayEnabled { get; init; }
+
+ ///
+ /// Checks the fields that must hold before this can be stored.
+ ///
+ ///
+ /// Separate from construction on purpose. A view model binds directly to these properties and
+ /// passes through empty and half-typed states on the way to a valid one; a constructor that threw
+ /// would make the editor unusable. The sync layer validates before sealing, and the codec
+ /// validates on decode, which are the two points where an invalid host would become durable.
+ ///
+ public bool TryValidate([NotNullWhen(false)] out string? error)
+ {
+ if (string.IsNullOrWhiteSpace(Label))
+ {
+ error = "A host needs a name.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(Hostname))
+ {
+ error = "A host needs a hostname or address.";
+ return false;
+ }
+
+ if (Port is < 1 or > 65535)
+ {
+ error = $"Port must be between 1 and 65535, not {Port}.";
+ return false;
+ }
+
+ if (JumpHostIds.AsSpan().Contains(Guid.Empty))
+ {
+ error = "A jump chain cannot contain an empty host id.";
+ return false;
+ }
+
+ error = null;
+ return true;
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/HostSecretCodec.cs b/src/DodoSSH.Client.Domain/HostSecretCodec.cs
new file mode 100644
index 0000000..9c79741
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/HostSecretCodec.cs
@@ -0,0 +1,211 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DodoSSH.Client.Domain;
+
+/// A decoded host payload, together with the schema version it was written at.
+/// The host.
+///
+/// The version the writing client used. May exceed
+/// , which is the case this type exists to make
+/// visible.
+///
+public sealed record HostSecretDocument(HostSecret Host, int SchemaVersion)
+{
+ ///
+ /// Whether this payload was written by a newer client than the one reading it.
+ ///
+ ///
+ ///
+ /// Such an item is safe to read — every field this build knows about decodes normally —
+ /// but must not be re-encoded, because fields added by the newer schema are not represented here
+ /// and would be dropped on write. Silently losing a field a colleague filled in is exactly the
+ /// class of bug that makes people stop trusting a synced vault.
+ ///
+ ///
+ /// So the rule is: display it, refuse to edit it, and tell the user to update. Preserving unknown
+ /// fields through a round trip was the alternative and it is worse — it means carrying opaque
+ /// JSON inside the domain model, which then has no usable structural equality and so breaks the
+ /// merge.
+ ///
+ ///
+ public bool IsReadOnly => SchemaVersion > HostSecretCodec.CurrentSchemaVersion;
+}
+
+///
+/// Encodes and decodes the plaintext inside a host item's encrypted payload.
+///
+///
+///
+/// JSON rather than the fixed binary layouts used elsewhere in the specification. The reasoning
+/// differs because the constraints differ: those layouts are hashed or signed, so canonicality is
+/// load-bearing, whereas this is only ever encrypted. What matters here instead is that the format
+/// grows a field without a migration — and the one thing that must not happen is an old client
+/// quietly dropping a field a new one wrote, which is what
+/// prevents.
+///
+///
+/// Encoding is deterministic: property order is fixed by declaration, and directives are held in a
+/// sorted map. That matters because the sync engine decides whether to push by comparing values, and
+/// a codec that produced different bytes for the same host would make every pass look like a change.
+///
+///
+public static class HostSecretCodec
+{
+ /// The schema version this build writes.
+ public const int CurrentSchemaVersion = 1;
+
+ /// Serialises a host to the bytes that get sealed.
+ /// The host is not valid for storage.
+ public static byte[] Encode(HostSecret host)
+ {
+ ArgumentNullException.ThrowIfNull(host);
+
+ if (!host.TryValidate(out var error))
+ {
+ throw new ArgumentException(error, nameof(host));
+ }
+
+ var options = new SortedDictionary(HostOption.NameComparer);
+ foreach (var option in host.Options)
+ {
+ options[option.Name] = option.Value;
+ }
+
+ var document = new HostPayloadDocument
+ {
+ SchemaVersion = CurrentSchemaVersion,
+ Label = host.Label,
+ Hostname = host.Hostname,
+ Port = host.Port,
+ Username = host.Username,
+ Notes = host.Notes,
+ JumpHostIds = [.. host.JumpHostIds],
+ Options = options,
+ RelayEnabled = host.RelayEnabled,
+ };
+
+ return JsonSerializer.SerializeToUtf8Bytes(
+ document, HostPayloadJsonContext.Default.HostPayloadDocument);
+ }
+
+ ///
+ /// Parses a decrypted payload.
+ ///
+ ///
+ /// Returns rather than throwing on anything malformed. These bytes
+ /// authenticated under a key only vault members hold, so a failure here is not an attack — it is
+ /// a bug in some client, or a truncated write. Either way it must degrade to one unreadable item
+ /// rather than an exception that aborts the whole sync pass and strands every other change.
+ ///
+ public static bool TryDecode(
+ ReadOnlySpan payload,
+ [NotNullWhen(true)] out HostSecretDocument? document)
+ {
+ document = null;
+
+ HostPayloadDocument? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize(
+ payload, HostPayloadJsonContext.Default.HostPayloadDocument);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+
+ if (parsed is null || parsed.SchemaVersion < 1)
+ {
+ return false;
+ }
+
+ if (!TryBuild(parsed, out var host))
+ {
+ return false;
+ }
+
+ document = new HostSecretDocument(host, parsed.SchemaVersion);
+ return true;
+ }
+
+ private static bool TryBuild(
+ HostPayloadDocument parsed,
+ [NotNullWhen(true)] out HostSecret? host)
+ {
+ host = null;
+
+ var directives = (parsed.Options ?? [])
+ .Select(entry => new HostOption(entry.Key, entry.Value));
+
+ if (!HostOptions.TryCreate(directives, out var options, out _))
+ {
+ return false;
+ }
+
+ var candidate = new HostSecret
+ {
+ Label = parsed.Label ?? string.Empty,
+ Hostname = parsed.Hostname ?? string.Empty,
+ Port = parsed.Port,
+ Username = parsed.Username,
+ Notes = parsed.Notes,
+ JumpHostIds = JumpChain.Create(parsed.JumpHostIds ?? []),
+ Options = options,
+ RelayEnabled = parsed.RelayEnabled,
+ };
+
+ if (!candidate.TryValidate(out _))
+ {
+ return false;
+ }
+
+ host = candidate;
+ return true;
+ }
+}
+
+///
+/// The serialised shape. Mutable and nullable because it models untrusted input.
+///
+///
+/// Deliberately separate from . A single type would force the domain model to
+/// carry the serialiser's requirements — a parameterless constructor, settable properties, nullable
+/// everything — and would let a decode failure produce a half-built host that looks valid to
+/// everything downstream.
+///
+internal sealed class HostPayloadDocument
+{
+ public int SchemaVersion { get; set; }
+
+ public string? Label { get; set; }
+
+ public string? Hostname { get; set; }
+
+ public int Port { get; set; }
+
+ public string? Username { get; set; }
+
+ public string? Notes { get; set; }
+
+ public Guid[]? JumpHostIds { get; set; }
+
+ ///
+ /// Sorted, so serialisation order is defined by the type rather than by insertion order — a
+ /// plain does not guarantee enumeration order, and this
+ /// encoding has to be reproducible.
+ ///
+ public SortedDictionary? Options { get; set; }
+
+ public bool RelayEnabled { get; set; }
+}
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ // An unknown member means a newer client wrote a field this build has no concept of. Skipping it
+ // is right; the guard against losing it lives in HostSecretDocument.IsReadOnly.
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
+[JsonSerializable(typeof(HostPayloadDocument))]
+internal sealed partial class HostPayloadJsonContext : JsonSerializerContext;
diff --git a/src/DodoSSH.Client.Domain/HostSecretMerge.cs b/src/DodoSSH.Client.Domain/HostSecretMerge.cs
new file mode 100644
index 0000000..5dd23df
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/HostSecretMerge.cs
@@ -0,0 +1,178 @@
+using System.Globalization;
+
+namespace DodoSSH.Client.Domain;
+
+///
+/// A value the merge had to override, kept so the user can see it and put it back.
+///
+///
+/// This record is the reason the merge is allowed to pick a winner at all. Choosing a side is only
+/// acceptable because the other side is preserved verbatim and surfaced; without that, a
+/// field-level merge is just last-writer-wins with extra steps.
+///
+///
+/// Which field, as a path. A directive reads Options[ServerAliveInterval] so the user is told
+/// which one rather than merely that "options" changed.
+///
+/// Whose intent was overridden.
+/// The value that survives, rendered for display.
+/// The value that lost, rendered for display.
+///
+/// True when what lost was a deletion rather than a different value.
+///
+public sealed record HostFieldConflict(
+ string Field,
+ MergeSide DiscardedSide,
+ string? Kept,
+ string? Discarded,
+ bool DiscardedWasRemoval);
+
+/// The merged host, and everything that had to be overridden to produce it.
+/// The host to store and push.
+/// Empty when the two sides were reconcilable field by field.
+public sealed record HostMergeResult(
+ HostSecret Merged,
+ IReadOnlyList Conflicts)
+{
+ /// Whether anything had to be overridden.
+ public bool HasConflicts => Conflicts.Count > 0;
+}
+
+///
+/// Merges two divergent versions of a host against the version they both started from.
+///
+///
+///
+/// Called when a pull brings down a change to an item that also has a local edit pending, and again
+/// when a push comes back Conflict carrying the server's current row. Both paths need the
+/// same answer, so both go through here.
+///
+///
+/// Scalar fields defer to the server on a genuine clash and the jump chain merges as a whole value,
+/// because its order is its meaning. Directives merge per name, which is what lets two people each
+/// add one and both keep it. See for why the remote side wins.
+///
+///
+public static class HostSecretMerge
+{
+ ///
+ /// Produces the merged host.
+ ///
+ ///
+ /// The version both sides branched from — the ciphertext the client retained when it queued its
+ /// local edit. Without it this degrades to a two-way diff, which cannot tell an edit from a
+ /// revert and so cannot avoid resurrecting deleted values.
+ ///
+ /// The pending local version.
+ /// The server's current version.
+ public static HostMergeResult Merge(HostSecret ancestor, HostSecret local, HostSecret remote)
+ {
+ ArgumentNullException.ThrowIfNull(ancestor);
+ ArgumentNullException.ThrowIfNull(local);
+ ArgumentNullException.ThrowIfNull(remote);
+
+ var conflicts = new List();
+
+ var merged = new HostSecret
+ {
+ Label = Text(nameof(HostSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts),
+ Hostname = Text(
+ nameof(HostSecret.Hostname), ancestor.Hostname, local.Hostname, remote.Hostname, conflicts),
+ Port = Field(
+ nameof(HostSecret.Port),
+ ancestor.Port,
+ local.Port,
+ remote.Port,
+ conflicts,
+ static port => port.ToString(CultureInfo.InvariantCulture)),
+ Username = Text(
+ nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts),
+ Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
+ JumpHostIds = Field(
+ nameof(HostSecret.JumpHostIds),
+ ancestor.JumpHostIds,
+ local.JumpHostIds,
+ remote.JumpHostIds,
+ conflicts,
+ FormatChain),
+ Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
+ RelayEnabled = Field(
+ nameof(HostSecret.RelayEnabled),
+ ancestor.RelayEnabled,
+ local.RelayEnabled,
+ remote.RelayEnabled,
+ conflicts,
+ static enabled => enabled ? "enabled" : "disabled"),
+ };
+
+ return new HostMergeResult(merged, conflicts);
+ }
+
+ private static string Text(
+ string name,
+ string? ancestor,
+ string? local,
+ string? remote,
+ List conflicts) =>
+ Field(name, ancestor, local, remote, conflicts, static value => value, StringComparer.Ordinal)!;
+
+ ///
+ /// A scalar clash always overrides the local side — see — so the
+ /// discarded side is fixed here rather than derived.
+ ///
+ private static T Field(
+ string name,
+ T ancestor,
+ T local,
+ T remote,
+ List conflicts,
+ Func format,
+ IEqualityComparer? comparer = null)
+ {
+ var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer);
+
+ if (merge.IsConflicted)
+ {
+ conflicts.Add(new HostFieldConflict(
+ name,
+ MergeSide.Local,
+ format(merge.Value),
+ merge.Discarded is null ? null : format(merge.Discarded),
+ DiscardedWasRemoval: false));
+ }
+
+ return merge.Value;
+ }
+
+ private static HostOptions MergeOptions(
+ HostOptions ancestor,
+ HostOptions local,
+ HostOptions remote,
+ List conflicts)
+ {
+ var merge = ThreeWayMerge.Map(
+ ancestor.ToNameMap(),
+ local.ToNameMap(),
+ remote.ToNameMap(),
+ HostOption.NameComparer,
+ StringComparer.Ordinal);
+
+ foreach (var conflict in merge.Conflicts)
+ {
+ conflicts.Add(new HostFieldConflict(
+ $"{nameof(HostSecret.Options)}[{conflict.Key}]",
+ conflict.DiscardedSide,
+ conflict.Kept,
+ conflict.Discarded,
+ conflict.DiscardedWasRemoval));
+ }
+
+ // The merged map is keyed by the same comparer, so uniqueness already holds and Create
+ // cannot throw here.
+ return HostOptions.Create(
+ merge.Merged.Select(entry => new HostOption(entry.Key, entry.Value)));
+ }
+
+ private static string FormatChain(JumpChain chain) =>
+ chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
+}
diff --git a/src/DodoSSH.Client.Domain/JumpChain.cs b/src/DodoSSH.Client.Domain/JumpChain.cs
new file mode 100644
index 0000000..ae56256
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/JumpChain.cs
@@ -0,0 +1,108 @@
+using System.Collections;
+using System.Diagnostics.CodeAnalysis;
+
+namespace DodoSSH.Client.Domain;
+
+///
+/// An ordered route to a host: the intermediate hosts to tunnel through, nearest hop first.
+///
+///
+///
+/// A dedicated type rather than a list of ids, for two reasons. It compares by contents, which the
+/// merge depends on — a plain on a record gets reference equality from
+/// the compiler-generated Equals, so every host would read as changed on every sync pass and
+/// two identical edits would register as a conflict. And it names the thing: the order here is the
+/// route, so this is not a set and must never be merged as one.
+///
+///
+/// Duplicate and empty hops are not rejected at construction. They arrive from a decrypted payload
+/// written by another client, and a constructor that threw would turn one bad item into a failed sync
+/// pass for every other item behind it. is where that is caught.
+///
+///
+public sealed class JumpChain : IReadOnlyList, IEquatable
+{
+ private readonly Guid[] hops;
+ private readonly int hash;
+
+ private JumpChain(Guid[] hops)
+ {
+ this.hops = hops;
+ hash = ComputeHash(hops);
+ }
+
+ /// A direct connection: no intermediate hosts.
+ public static JumpChain Empty { get; } = new([]);
+
+ ///
+ public int Count => hops.Length;
+
+ ///
+ public Guid this[int index] => hops[index];
+
+ /// Copies a sequence of hops, preserving order.
+ public static JumpChain Create(IEnumerable hops)
+ {
+ ArgumentNullException.ThrowIfNull(hops);
+
+ var copy = hops.ToArray();
+ return copy.Length == 0 ? Empty : new JumpChain(copy);
+ }
+
+ /// Copies a span of hops, preserving order.
+ public static JumpChain Create(ReadOnlySpan hops) =>
+ hops.IsEmpty ? Empty : new JumpChain(hops.ToArray());
+
+ ///
+ public bool Equals(JumpChain? other)
+ {
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ return other is not null
+ && other.hash == hash
+ && hops.AsSpan().SequenceEqual(other.hops);
+ }
+
+ ///
+ public override bool Equals(object? obj) => Equals(obj as JumpChain);
+
+ ///
+ public override int GetHashCode() => hash;
+
+ ///
+ public IEnumerator GetEnumerator() => ((IEnumerable)hops).GetEnumerator();
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator() => hops.GetEnumerator();
+
+ /// The hops, without copying.
+ public ReadOnlySpan AsSpan() => hops;
+
+ /// Contents equality, tolerating nulls on either side.
+ [SuppressMessage(
+ "Usage",
+ "CA2225:Operator overloads have named alternates",
+ Justification = "Equals(JumpChain) is the named alternate.")]
+ public static bool operator ==(JumpChain? left, JumpChain? right) =>
+ left is null ? right is null : left.Equals(right);
+
+ /// Contents inequality.
+ public static bool operator !=(JumpChain? left, JumpChain? right) => !(left == right);
+
+ private static int ComputeHash(Guid[] hops)
+ {
+ // Order-sensitive, because reordering a route changes which machine is reached through which.
+ var accumulator = new HashCode();
+ accumulator.Add(hops.Length);
+
+ foreach (var hop in hops)
+ {
+ accumulator.Add(hop);
+ }
+
+ return accumulator.ToHashCode();
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/ThreeWayMerge.cs b/src/DodoSSH.Client.Domain/ThreeWayMerge.cs
new file mode 100644
index 0000000..519d445
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/ThreeWayMerge.cs
@@ -0,0 +1,272 @@
+using System.Runtime.InteropServices;
+
+namespace DodoSSH.Client.Domain;
+
+/// Which of the two diverging replicas a value came from.
+public enum MergeSide
+{
+ /// The edit made on this machine.
+ Local = 0,
+
+ /// The edit that arrived from the server.
+ Remote = 1,
+}
+
+/// How a single field was resolved.
+public enum MergeDecision
+{
+ ///
+ /// Both sides hold the same value — either neither changed it, or both made the identical
+ /// change. Distinguishing those two is not useful: the outcome is the same and no one is
+ /// surprised.
+ ///
+ Agreed = 0,
+
+ /// Only this machine changed it.
+ TookLocal = 1,
+
+ /// Only the server side changed it.
+ TookRemote = 2,
+
+ /// Both changed it, differently. One value survives and the other is reported.
+ Conflicted = 3,
+}
+
+/// The outcome of merging one field.
+/// The field's type.
+/// The value to keep.
+/// How it was resolved.
+///
+/// The value that lost, meaningful only when is
+/// . Never simply dropped: the caller is expected to record it.
+///
+[StructLayout(LayoutKind.Auto)]
+public readonly record struct FieldMerge(T Value, MergeDecision Decision, T? Discarded)
+{
+ /// Whether both sides changed this field to different values.
+ public bool IsConflicted => Decision == MergeDecision.Conflicted;
+}
+
+/// A key whose value both sides changed, or which one side removed while the other edited.
+/// Key type.
+/// Value type.
+/// The key in question.
+/// The value that survives, or if the key is removed.
+/// Which replica's intent was overridden.
+///
+/// The value that lost, or when what lost was a removal.
+///
+///
+/// True when the overridden intent was to remove the key rather than to set it to a different value.
+///
+[StructLayout(LayoutKind.Auto)]
+public readonly record struct MapConflict(
+ TKey Key,
+ TValue? Kept,
+ MergeSide DiscardedSide,
+ TValue? Discarded,
+ bool DiscardedWasRemoval);
+
+/// The outcome of merging a keyed collection.
+/// Key type.
+/// Value type.
+/// The resulting collection.
+/// Every key where the two sides disagreed.
+[StructLayout(LayoutKind.Auto)]
+public readonly record struct MapMerge(
+ IReadOnlyDictionary Merged,
+ IReadOnlyList> Conflicts)
+ where TKey : notnull;
+
+///
+/// The merge primitives: resolve a field, or a keyed collection, from a common ancestor and two
+/// divergent versions.
+///
+///
+///
+/// The server cannot do any of this — it cannot read a payload, so it cannot merge one. That is why
+/// a conflicting push comes back with the server's current row rather than being resolved for us,
+/// and why this code is the last line of defence against losing a credential.
+///
+///
+/// Why the remote side wins a genuine clash. It has to be one of them, and it has to be the
+/// same one on every replica. If each client kept its own value, two clients would resolve the same
+/// triple in opposite directions, each push would conflict with the other's, and they would ping-pong
+/// forever without converging. Deferring to the value already on the server converges in one round.
+///
+///
+/// The losing value is never discarded silently. Every primitive returns it, the item-level
+/// merge collects them, and the sync engine writes them to a conflict log the user can act on. This
+/// is the whole point: a merge that quietly drops the password someone just typed is worse than one
+/// that refuses to merge at all.
+///
+///
+public static class ThreeWayMerge
+{
+ ///
+ /// Resolves one field.
+ ///
+ /// The value both sides started from.
+ /// This machine's value.
+ /// The server's value.
+ /// Value comparison; defaults to .
+ public static FieldMerge Scalar(
+ T ancestor,
+ T local,
+ T remote,
+ IEqualityComparer? comparer = null)
+ {
+ comparer ??= EqualityComparer.Default;
+
+ // Checked first, so two people making the identical edit is agreement rather than a
+ // conflict they have to be bothered about.
+ if (comparer.Equals(local, remote))
+ {
+ return new FieldMerge(local, MergeDecision.Agreed, default);
+ }
+
+ if (comparer.Equals(local, ancestor))
+ {
+ return new FieldMerge(remote, MergeDecision.TookRemote, default);
+ }
+
+ if (comparer.Equals(remote, ancestor))
+ {
+ return new FieldMerge(local, MergeDecision.TookLocal, default);
+ }
+
+ return new FieldMerge(remote, MergeDecision.Conflicted, local);
+ }
+
+ ///
+ /// Resolves a keyed collection key by key.
+ ///
+ ///
+ ///
+ /// Per-key rather than whole-collection, which is the difference between two people each adding
+ /// a directive and both keeping it, versus one of them losing theirs to a conflict. That is the
+ /// single most visible benefit of a field-level merge over last-writer-wins.
+ ///
+ ///
+ /// An edit beats a removal. Where one side deleted a key and the other changed its value,
+ /// the value survives and the removal is reported. The asymmetry is deliberate and it is not a
+ /// preference: re-applying a removal costs one click, while a discarded value may be the only
+ /// copy of something the user cannot reconstruct.
+ ///
+ ///
+ /// The state both sides started from.
+ /// This machine's state.
+ /// The server's state.
+ /// Defines key identity.
+ /// Value comparison; defaults to .
+ public static MapMerge Map(
+ IReadOnlyDictionary ancestor,
+ IReadOnlyDictionary local,
+ IReadOnlyDictionary remote,
+ IEqualityComparer keyComparer,
+ IEqualityComparer? valueComparer = null)
+ where TKey : notnull
+ {
+ ArgumentNullException.ThrowIfNull(ancestor);
+ ArgumentNullException.ThrowIfNull(local);
+ ArgumentNullException.ThrowIfNull(remote);
+ ArgumentNullException.ThrowIfNull(keyComparer);
+
+ valueComparer ??= EqualityComparer.Default;
+
+ var merged = new Dictionary(keyComparer);
+ var conflicts = new List>();
+
+ foreach (var key in UnionOfKeys(ancestor, local, remote, keyComparer))
+ {
+ var a = Slot.For(ancestor, key);
+ var l = Slot.For(local, key);
+ var r = Slot.For(remote, key);
+
+ var resolved = ResolveKey(key, a, l, r, valueComparer, conflicts);
+
+ if (resolved.Present)
+ {
+ merged[key] = resolved.Value!;
+ }
+ }
+
+ return new MapMerge(merged, conflicts);
+ }
+
+ /// One key's state on one replica: present with a value, or absent.
+ [StructLayout(LayoutKind.Auto)]
+ private readonly record struct Slot(bool Present, TValue? Value)
+ {
+ internal bool Matches(in Slot other, IEqualityComparer comparer) =>
+ Present == other.Present
+ && (!Present || comparer.Equals(Value!, other.Value!));
+ }
+
+ private static class Slot
+ {
+ internal static Slot For(
+ IReadOnlyDictionary source,
+ TKey key) =>
+ source.TryGetValue(key, out var value)
+ ? new Slot(true, value)
+ : new Slot(false, default);
+ }
+
+ private static Slot ResolveKey(
+ TKey key,
+ in Slot ancestor,
+ in Slot local,
+ in Slot remote,
+ IEqualityComparer valueComparer,
+ List> conflicts)
+ {
+ if (local.Matches(remote, valueComparer))
+ {
+ return local;
+ }
+
+ if (local.Matches(ancestor, valueComparer))
+ {
+ return remote;
+ }
+
+ if (remote.Matches(ancestor, valueComparer))
+ {
+ return local;
+ }
+
+ // Both sides moved. Prefer whichever still holds a value, so an edit outlives a removal;
+ // where both hold one, defer to the server so every replica converges the same way.
+ var winner = remote.Present ? remote : local;
+ var loserSide = remote.Present ? MergeSide.Local : MergeSide.Remote;
+ var loser = remote.Present ? local : remote;
+
+ conflicts.Add(new MapConflict(
+ key,
+ winner.Value,
+ loserSide,
+ loser.Present ? loser.Value : default,
+ DiscardedWasRemoval: !loser.Present));
+
+ return winner;
+ }
+
+ private static IEnumerable UnionOfKeys(
+ IReadOnlyDictionary ancestor,
+ IReadOnlyDictionary local,
+ IReadOnlyDictionary remote,
+ IEqualityComparer keyComparer)
+ where TKey : notnull
+ {
+ var seen = new HashSet(keyComparer);
+
+ foreach (var key in ancestor.Keys.Concat(local.Keys).Concat(remote.Keys))
+ {
+ if (seen.Add(key))
+ {
+ yield return key;
+ }
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/packages.lock.json b/src/DodoSSH.Client.Domain/packages.lock.json
new file mode 100644
index 0000000..722652b
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/packages.lock.json
@@ -0,0 +1,19 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "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=="
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Storage/CacheMapping.cs b/src/DodoSSH.Client.Storage/CacheMapping.cs
new file mode 100644
index 0000000..d974802
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/CacheMapping.cs
@@ -0,0 +1,73 @@
+using System.Text.Json;
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Client.Storage;
+
+///
+/// Translates between the payload columns and .
+///
+///
+/// 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.
+///
+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);
+}
+
+///
+/// Serialises the plaintext columns so they can be sealed as one unit.
+///
+///
+///
+/// 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.
+///
+///
+/// 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.
+///
+///
+internal static class PlaintextFieldsCodec
+{
+ internal static byte[] Encode(SyncPlaintextFields fields) =>
+ JsonSerializer.SerializeToUtf8Bytes(
+ fields, DodoSshJsonContext.Default.SyncPlaintextFields);
+
+ ///
+ /// The fields, or 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.
+ ///
+ internal static SyncPlaintextFields? TryDecode(ReadOnlySpan utf8)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(
+ utf8, DodoSshJsonContext.Default.SyncPlaintextFields);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Storage/CacheRows.cs b/src/DodoSSH.Client.Storage/CacheRows.cs
new file mode 100644
index 0000000..d99c13e
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/CacheRows.cs
@@ -0,0 +1,283 @@
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Client.Storage;
+
+///
+/// What unlock needs, and nothing else.
+///
+///
+///
+/// A single row: is always . 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.
+///
+///
+/// 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.
+///
+///
+internal sealed class UnlockMaterialRow
+{
+ /// The only legal primary key.
+ 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; }
+
+ /// The secret bundle, wrapped under the passphrase-derived key. Ciphertext.
+ public byte[] WrappedPrivateKey { get; set; } = [];
+
+ public string KdfAlgorithm { get; set; } = string.Empty;
+
+ public byte[] KdfSalt { get; set; } = [];
+
+ /// Kibibytes, matching both libsodium and the storage column on the server.
+ public int KdfMemoryKibibytes { get; set; }
+
+ public int KdfPasses { get; set; }
+
+ public int KdfParallelism { get; set; }
+
+ public DateTimeOffset UpdatedAtUtc { get; set; }
+}
+
+/// A vault the user can reach, with the grant that opens it.
+///
+/// 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.
+///
+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; }
+
+ ///
+ /// 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.
+ ///
+ public byte[]? WrappedVaultKey { get; set; }
+
+ public bool RekeyRequired { get; set; }
+
+ public DateTimeOffset UpdatedAtUtc { get; set; }
+}
+
+///
+/// The last state of an item that the server confirmed.
+///
+///
+///
+/// Strictly a mirror: this row is what the server said, never what the user has typed but not yet
+/// pushed. Local edits live in , 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.
+///
+///
+/// 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.
+///
+///
+internal sealed class CachedItemRow
+{
+ public Guid VaultId { get; set; }
+
+ public SyncEntityType EntityType { get; set; }
+
+ public Guid EntityId { get; set; }
+
+ /// The server-assigned item version, and the value a push must expect.
+ 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; }
+
+ ///
+ /// The plaintext columns the server needs, sealed under the LocalCacheKey.
+ ///
+ ///
+ /// 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.
+ ///
+ public byte[]? ProtectedFields { get; set; }
+
+ /// A tombstone. Deletes are never hard, or an offline client could not learn of them.
+ public bool IsDeleted { get; set; }
+
+ public DateTimeOffset UpdatedAtUtc { get; set; }
+}
+
+///
+/// A local change that the server has not yet accepted.
+///
+///
+///
+/// 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.
+///
+///
+/// and exist so a permanently rejected operation can be
+/// parked and shown rather than retried forever. An operation the server calls
+/// Invalid will never succeed on retry, and spinning on it would block every change queued
+/// behind it.
+///
+///
+internal sealed class OutboxRow
+{
+ /// Local, monotonic. Defines the order changes are pushed in.
+ public long Sequence { get; set; }
+
+ ///
+ /// The server's idempotency key for this operation.
+ ///
+ ///
+ /// Re-minted whenever the payload changes — see OutboxStore.QueueAsync. Keeping the old id
+ /// across an edit would let the server answer Duplicate for an operation whose contents
+ /// have since changed, silently discarding the newer edit.
+ ///
+ 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; }
+
+ /// The version the client believes the server holds. Null means create.
+ 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; }
+
+ /// Set when the server rejected this outright, so it stops being retried.
+ public bool IsParked { get; set; }
+}
+
+/// Where a vault's pull has reached.
+internal sealed class SyncStateRow
+{
+ public Guid VaultId { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public string? Cursor { get; set; }
+
+ public uint KeyGeneration { get; set; }
+
+ public DateTimeOffset? LastPulledAtUtc { get; set; }
+
+ public DateTimeOffset? LastPushedAtUtc { get; set; }
+
+ ///
+ /// Observed difference between the server's clock and this machine's, from the last pull.
+ ///
+ ///
+ /// 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.
+ ///
+ public long ServerTimeSkewMs { get; set; }
+}
+
+/// Something the merge had to override, or an item that could not be processed.
+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; }
+
+ /// The discarded values, sealed under the LocalCacheKey.
+ ///
+ /// 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.
+ ///
+ public byte[] Detail { get; set; } = [];
+
+ public DateTimeOffset DetectedAtUtc { get; set; }
+
+ public bool Acknowledged { get; set; }
+}
diff --git a/src/DodoSSH.Client.Storage/ClientCacheContext.cs b/src/DodoSSH.Client.Storage/ClientCacheContext.cs
new file mode 100644
index 0000000..7069202
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/ClientCacheContext.cs
@@ -0,0 +1,165 @@
+using DodoSSH.Contracts;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace DodoSSH.Client.Storage;
+
+///
+/// Stores a timestamp as Unix milliseconds.
+///
+///
+///
+/// Not a preference. SQLite has no date type, and EF's default mapping for
+/// is a text form that it then refuses to order or compare — any
+/// query with ORDER BY or a range filter on such a column throws
+/// at execution time, not at model build. Collecting tombstones
+/// older than a cutoff and listing conflicts newest-first are both exactly that shape, so this was a
+/// crash waiting for the first user with a deleted host. Found by the tests that do both.
+///
+///
+/// An integer also sorts and compares correctly by construction, which the text form does not once
+/// two rows carry different UTC offsets. The cost is losing sub-millisecond precision and normalising
+/// to UTC — neither of which matters here, and both of which docs/crypto.md §7 already does to every
+/// timestamp it signs over.
+///
+///
+internal sealed class UnixMillisecondsConverter : ValueConverter
+{
+ /// Public because EF instantiates this reflectively and needs a public constructor.
+ public UnixMillisecondsConverter()
+ : base(
+ value => value.ToUnixTimeMilliseconds(),
+ value => DateTimeOffset.FromUnixTimeMilliseconds(value))
+ {
+ }
+}
+
+///
+/// The local cache database.
+///
+///
+///
+/// Public only because the migrations tooling needs to reach it. The row types stay internal and
+/// there are no properties: callers go through the stores, which is what
+/// keeps the sealing of protected columns from being something a call site can forget. Entities are
+/// registered explicitly in and reached with
+/// .
+///
+///
+/// Migrations rather than EnsureCreated, even for a cache. The item rows are indeed disposable
+/// — worst case they re-pull from a null cursor — but is not: dropping
+/// it would mean a user who upgrades while offline cannot open their vault until they are back on the
+/// network, which is exactly the situation the offline unlock exists for.
+///
+///
+public sealed class ClientCacheContext(DbContextOptions options)
+ : DbContext(options)
+{
+ ///
+ ///
+ /// Applied as a convention rather than per property, so a timestamp added later cannot be the one
+ /// that is left un-converted — which would fail only when something eventually sorted by it.
+ ///
+ protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(configurationBuilder);
+
+ configurationBuilder.Properties().HaveConversion();
+ }
+
+ ///
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(modelBuilder);
+
+ ConfigureUnlockMaterial(modelBuilder);
+ ConfigureVaults(modelBuilder);
+ ConfigureItems(modelBuilder);
+ ConfigureOutbox(modelBuilder);
+ ConfigureSyncState(modelBuilder);
+ ConfigureConflicts(modelBuilder);
+ }
+
+ private static void ConfigureUnlockMaterial(ModelBuilder modelBuilder) =>
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable(
+ "unlock_material",
+ // One server and one user per cache file. The constraint is here rather than only in
+ // code so that a second row cannot appear through any path at all — including a
+ // future migration written by someone who has not read this comment.
+ table => table.HasCheckConstraint(
+ "ck_unlock_material_singleton",
+ $"id = {UnlockMaterialRow.SingletonId}"));
+
+ entity.HasKey(row => row.Id);
+ entity.Property(row => row.Id).ValueGeneratedNever();
+ entity.Property(row => row.ServerUrl).IsRequired();
+ entity.Property(row => row.Issuer).IsRequired();
+ entity.Property(row => row.Subject).IsRequired();
+ entity.Property(row => row.WrappedPrivateKey).IsRequired();
+ entity.Property(row => row.KdfAlgorithm).IsRequired();
+ entity.Property(row => row.KdfSalt).IsRequired();
+ });
+
+ private static void ConfigureVaults(ModelBuilder modelBuilder) =>
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("vault");
+ entity.HasKey(row => row.VaultId);
+ entity.Property(row => row.VaultId).ValueGeneratedNever();
+ entity.Property(row => row.Name).IsRequired();
+ });
+
+ private static void ConfigureItems(ModelBuilder modelBuilder) =>
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("item");
+
+ // Composite rather than the entity id alone. Ids are UUIDv7 and globally unique in
+ // practice, but making the vault part of the identity means a row can never be read out
+ // of the wrong vault by a query that forgot to filter.
+ entity.HasKey(row => new { row.VaultId, row.EntityType, row.EntityId });
+
+ entity.HasIndex(row => new { row.VaultId, row.EntityType });
+ entity.HasIndex(row => new { row.VaultId, row.ChangeSequence });
+ });
+
+ private static void ConfigureOutbox(ModelBuilder modelBuilder) =>
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("outbox");
+ entity.HasKey(row => row.Sequence);
+ entity.Property(row => row.Sequence).ValueGeneratedOnAdd();
+
+ // At most one pending operation per item, enforced by the database rather than by
+ // convention. Two queued edits to one item would have to be pushed in order, and the
+ // second would need the version the first produced — which is not known when it is
+ // queued. Coalescing into this single row avoids the problem instead of managing it.
+ entity.HasIndex(row => new { row.VaultId, row.EntityType, row.EntityId }).IsUnique();
+
+ // The drain order.
+ entity.HasIndex(row => new { row.VaultId, row.IsParked, row.Sequence });
+
+ entity.HasIndex(row => row.OperationId).IsUnique();
+ });
+
+ private static void ConfigureSyncState(ModelBuilder modelBuilder) =>
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("sync_state");
+ entity.HasKey(row => row.VaultId);
+ entity.Property(row => row.VaultId).ValueGeneratedNever();
+ });
+
+ private static void ConfigureConflicts(ModelBuilder modelBuilder) =>
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("conflict");
+ entity.HasKey(row => row.Id);
+ entity.Property(row => row.Id).ValueGeneratedNever();
+ entity.Property(row => row.Detail).IsRequired();
+ entity.HasIndex(row => new { row.VaultId, row.Acknowledged });
+ entity.HasIndex(row => new { row.VaultId, row.EntityType, row.EntityId });
+ });
+}
diff --git a/src/DodoSSH.Client.Storage/ClientCacheFactory.cs b/src/DodoSSH.Client.Storage/ClientCacheFactory.cs
new file mode 100644
index 0000000..483fe40
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/ClientCacheFactory.cs
@@ -0,0 +1,133 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace DodoSSH.Client.Storage;
+
+///
+/// Opens the local cache and hands out short-lived contexts.
+///
+///
+///
+/// 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 is not thread-safe. Each store
+/// operation takes a context, does one unit of work and disposes it; SQLite serialises the writes.
+///
+///
+/// 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.
+///
+///
+public sealed class ClientCacheFactory : IDbContextFactory, IDisposable
+{
+ private readonly DbContextOptions options;
+
+ ///
+ /// 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.
+ ///
+ private readonly SqliteConnection? keepAlive;
+
+ private bool disposed;
+
+ private ClientCacheFactory(string connectionString, SqliteConnection? keepAlive)
+ {
+ this.keepAlive = keepAlive;
+
+ options = new DbContextOptionsBuilder()
+ .UseSqlite(connectionString)
+ .UseSnakeCaseNamingConvention()
+ .Options;
+ }
+
+ /// Opens, or creates, a cache file.
+ /// Full path to the SQLite file.
+ 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);
+ }
+
+ ///
+ /// Opens a private in-memory cache, for tests and for a session that must leave no trace.
+ ///
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ ///
+ public ClientCacheContext CreateDbContext()
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+
+ return new ClientCacheContext(options);
+ }
+
+ ///
+ /// Brings the schema up to date.
+ ///
+ ///
+ /// 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.
+ ///
+ public async Task MigrateAsync(CancellationToken cancellationToken)
+ {
+ var context = CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+ await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ keepAlive?.Dispose();
+ }
+}
+
+///
+/// Supplies a context to dotnet ef.
+///
+///
+/// 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.
+///
+public sealed class ClientCacheDesignTimeFactory : IDesignTimeDbContextFactory
+{
+ ///
+ public ClientCacheContext CreateDbContext(string[] args) =>
+ ClientCacheFactory.ForFile("dodossh-design-time.db").CreateDbContext();
+}
diff --git a/src/DodoSSH.Client.Storage/ConflictStore.cs b/src/DodoSSH.Client.Storage/ConflictStore.cs
new file mode 100644
index 0000000..89aee39
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/ConflictStore.cs
@@ -0,0 +1,142 @@
+using DodoSSH.Contracts;
+using Microsoft.EntityFrameworkCore;
+
+namespace DodoSSH.Client.Storage;
+
+///
+/// What the merge had to override, and what it could not process.
+///
+///
+///
+/// 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.
+///
+///
+/// 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.
+///
+///
+public sealed class ConflictStore(
+ IDbContextFactory contexts,
+ LocalCacheProtector protector,
+ TimeProvider clock)
+{
+ ///
+ /// Records a conflict.
+ ///
+ ///
+ /// 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.
+ ///
+ public async Task RecordAsync(
+ Guid vaultId,
+ SyncEntityType entityType,
+ Guid entityId,
+ ConflictKind kind,
+ ReadOnlyMemory 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;
+ }
+
+ /// Reads conflicts for a vault, newest first.
+ public async Task> ListAsync(
+ Guid vaultId,
+ bool includeAcknowledged,
+ CancellationToken cancellationToken)
+ {
+ var context = contexts.CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+
+ var query = context.Set()
+ .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)];
+ }
+
+ /// Marks a conflict as dealt with.
+ ///
+ /// 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.
+ ///
+ public async Task AcknowledgeAsync(Guid conflictId, CancellationToken cancellationToken)
+ {
+ var context = contexts.CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+
+ var updated = await context.Set()
+ .Where(row => row.Id == conflictId)
+ .ExecuteUpdateAsync(row => row.SetProperty(r => r.Acknowledged, true), cancellationToken)
+ .ConfigureAwait(false);
+
+ return updated > 0;
+ }
+
+ /// Removes an acknowledged conflict for good.
+ public async Task DiscardAsync(Guid conflictId, CancellationToken cancellationToken)
+ {
+ var context = contexts.CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+
+ var removed = await context.Set()
+ .Where(row => row.Id == conflictId && row.Acknowledged)
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ return removed > 0;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+}
diff --git a/src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj b/src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj
new file mode 100644
index 0000000..f52b817
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Storage/ItemStore.cs b/src/DodoSSH.Client.Storage/ItemStore.cs
new file mode 100644
index 0000000..ac09465
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/ItemStore.cs
@@ -0,0 +1,192 @@
+using DodoSSH.Contracts;
+using Microsoft.EntityFrameworkCore;
+
+namespace DodoSSH.Client.Storage;
+
+///
+/// The mirror of what the server holds.
+///
+///
+///
+/// Every row here is server-confirmed state. Nothing the user has typed but not yet pushed appears in
+/// this table — that lives in , 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.
+///
+///
+/// Requires an unlocked , 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.
+///
+///
+public sealed class ItemStore(
+ IDbContextFactory contexts,
+ LocalCacheProtector protector)
+{
+ /// Reads one item, tombstones included.
+ public async Task 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()
+ .AsNoTracking()
+ .SingleOrDefaultAsync(
+ r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ return row is null ? null : ToStored(row);
+ }
+
+ /// Reads every item of one kind in a vault.
+ /// The vault.
+ /// Kind of item.
+ ///
+ /// 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.
+ ///
+ /// Cancellation token.
+ public async Task> ListAsync(
+ Guid vaultId,
+ SyncEntityType entityType,
+ bool includeDeleted,
+ CancellationToken cancellationToken)
+ {
+ var context = contexts.CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+
+ var query = context.Set()
+ .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)];
+ }
+
+ ///
+ /// Writes the server's version of an item, creating or replacing the row.
+ ///
+ ///
+ /// 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.
+ ///
+ 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()
+ .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);
+ }
+
+ /// Removes a tombstone whose change has been seen by everything that needed it.
+ ///
+ /// 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.
+ ///
+ public async Task CollectTombstonesAsync(
+ Guid vaultId,
+ DateTimeOffset olderThan,
+ CancellationToken cancellationToken)
+ {
+ var context = contexts.CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+
+ return await context.Set()
+ .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);
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+}
diff --git a/src/DodoSSH.Client.Storage/LocalCacheProtector.cs b/src/DodoSSH.Client.Storage/LocalCacheProtector.cs
new file mode 100644
index 0000000..8fda24f
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/LocalCacheProtector.cs
@@ -0,0 +1,122 @@
+using System.Security.Cryptography;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Storage;
+
+///
+/// Seals the few things the local cache holds that are not already ciphertext.
+///
+///
+///
+/// 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.
+///
+///
+/// What this is and is not worth. 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
+/// not 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.
+///
+///
+/// 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.
+///
+///
+public sealed class LocalCacheProtector : IDisposable
+{
+ private readonly byte[] key = new byte[CryptoSpec.SymmetricKeySize];
+ private bool disposed;
+
+ private LocalCacheProtector(MasterKey master) => master.DeriveLocalCacheKey(key);
+
+ ///
+ /// Derives the cache key from an unlocked master key.
+ ///
+ ///
+ /// 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.
+ ///
+ public static LocalCacheProtector From(MasterKey master)
+ {
+ ArgumentNullException.ThrowIfNull(master);
+
+ return new LocalCacheProtector(master);
+ }
+
+ /// Seals a cache record, binding it to the row that will hold it.
+ public byte[] Protect(
+ CryptoSpec.AadResourceType resourceType,
+ Guid recordId,
+ ReadOnlySpan plaintext)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+
+ return DshCrypto.Seal(key, plaintext, DshAad.LocalCache(resourceType, recordId));
+ }
+
+ ///
+ /// Opens a sealed cache record.
+ ///
+ ///
+ /// The plaintext, or 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.
+ ///
+ public byte[]? TryUnprotect(
+ CryptoSpec.AadResourceType resourceType,
+ Guid recordId,
+ ReadOnlySpan envelope)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+
+ return DshCrypto.Open(key, envelope, DshAad.LocalCache(resourceType, recordId));
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ CryptographicOperations.ZeroMemory(key);
+ }
+}
+
+///
+/// Maps a syncable entity type onto the resource type its AAD binds.
+///
+///
+/// A switch rather than a cast, even though the two enums happen to be adjacent. They are not the
+/// same list: 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.
+///
+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."),
+ };
+}
diff --git a/src/DodoSSH.Client.Storage/Migrations/20260729080003_InitialCache.Designer.cs b/src/DodoSSH.Client.Storage/Migrations/20260729080003_InitialCache.Designer.cs
new file mode 100644
index 0000000..f91ea76
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/Migrations/20260729080003_InitialCache.Designer.cs
@@ -0,0 +1,408 @@
+//
+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
+ {
+ ///
+ 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("VaultId")
+ .HasColumnType("TEXT")
+ .HasColumnName("vault_id");
+
+ b.Property("EntityType")
+ .HasColumnType("INTEGER")
+ .HasColumnName("entity_type");
+
+ b.Property("EntityId")
+ .HasColumnType("TEXT")
+ .HasColumnName("entity_id");
+
+ b.Property("AadVersion")
+ .HasColumnType("INTEGER")
+ .HasColumnName("aad_version");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("INTEGER")
+ .HasColumnName("change_sequence");
+
+ b.Property("DataKeyId")
+ .HasColumnType("TEXT")
+ .HasColumnName("data_key_id");
+
+ b.Property("IsDeleted")
+ .HasColumnType("INTEGER")
+ .HasColumnName("is_deleted");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("key_generation");
+
+ b.Property("Payload")
+ .HasColumnType("BLOB")
+ .HasColumnName("payload");
+
+ b.Property("ProtectedFields")
+ .HasColumnType("BLOB")
+ .HasColumnName("protected_fields");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("Version")
+ .HasColumnType("INTEGER")
+ .HasColumnName("version");
+
+ b.Property("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("VaultId")
+ .HasColumnType("TEXT")
+ .HasColumnName("vault_id");
+
+ b.Property("IsPersonal")
+ .HasColumnType("INTEGER")
+ .HasColumnName("is_personal");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("key_generation");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("name");
+
+ b.Property("Permissions")
+ .HasColumnType("INTEGER")
+ .HasColumnName("permissions");
+
+ b.Property("RekeyRequired")
+ .HasColumnType("INTEGER")
+ .HasColumnName("rekey_required");
+
+ b.Property("TeamId")
+ .HasColumnType("TEXT")
+ .HasColumnName("team_id");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("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("Id")
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("Acknowledged")
+ .HasColumnType("INTEGER")
+ .HasColumnName("acknowledged");
+
+ b.Property("Detail")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("detail");
+
+ b.Property("DetectedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("detected_at_utc");
+
+ b.Property("EntityId")
+ .HasColumnType("TEXT")
+ .HasColumnName("entity_id");
+
+ b.Property("EntityType")
+ .HasColumnType("INTEGER")
+ .HasColumnName("entity_type");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER")
+ .HasColumnName("kind");
+
+ b.Property("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("Sequence")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("sequence");
+
+ b.Property("AadVersion")
+ .HasColumnType("INTEGER")
+ .HasColumnName("aad_version");
+
+ b.Property("AncestorAadVersion")
+ .HasColumnType("INTEGER")
+ .HasColumnName("ancestor_aad_version");
+
+ b.Property("AncestorDataKeyId")
+ .HasColumnType("TEXT")
+ .HasColumnName("ancestor_data_key_id");
+
+ b.Property("AncestorKeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("ancestor_key_generation");
+
+ b.Property("AncestorPayload")
+ .HasColumnType("BLOB")
+ .HasColumnName("ancestor_payload");
+
+ b.Property("AncestorProtectedFields")
+ .HasColumnType("BLOB")
+ .HasColumnName("ancestor_protected_fields");
+
+ b.Property("AncestorVersion")
+ .HasColumnType("INTEGER")
+ .HasColumnName("ancestor_version");
+
+ b.Property("AncestorWrappedDataKey")
+ .HasColumnType("BLOB")
+ .HasColumnName("ancestor_wrapped_data_key");
+
+ b.Property("Attempts")
+ .HasColumnType("INTEGER")
+ .HasColumnName("attempts");
+
+ b.Property("DataKeyId")
+ .HasColumnType("TEXT")
+ .HasColumnName("data_key_id");
+
+ b.Property("EntityId")
+ .HasColumnType("TEXT")
+ .HasColumnName("entity_id");
+
+ b.Property("EntityType")
+ .HasColumnType("INTEGER")
+ .HasColumnName("entity_type");
+
+ b.Property("ExpectedVersion")
+ .HasColumnType("INTEGER")
+ .HasColumnName("expected_version");
+
+ b.Property("IsParked")
+ .HasColumnType("INTEGER")
+ .HasColumnName("is_parked");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("key_generation");
+
+ b.Property("LastError")
+ .HasColumnType("TEXT")
+ .HasColumnName("last_error");
+
+ b.Property("Operation")
+ .HasColumnType("INTEGER")
+ .HasColumnName("operation");
+
+ b.Property("OperationId")
+ .HasColumnType("TEXT")
+ .HasColumnName("operation_id");
+
+ b.Property("Payload")
+ .HasColumnType("BLOB")
+ .HasColumnName("payload");
+
+ b.Property("ProtectedFields")
+ .HasColumnType("BLOB")
+ .HasColumnName("protected_fields");
+
+ b.Property("QueuedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("queued_at_utc");
+
+ b.Property("VaultId")
+ .HasColumnType("TEXT")
+ .HasColumnName("vault_id");
+
+ b.Property("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("VaultId")
+ .HasColumnType("TEXT")
+ .HasColumnName("vault_id");
+
+ b.Property("Cursor")
+ .HasColumnType("TEXT")
+ .HasColumnName("cursor");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("key_generation");
+
+ b.Property("LastPulledAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("last_pulled_at_utc");
+
+ b.Property("LastPushedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("last_pushed_at_utc");
+
+ b.Property("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("Id")
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property("DisplayName")
+ .HasColumnType("TEXT")
+ .HasColumnName("display_name");
+
+ b.Property("Email")
+ .HasColumnType("TEXT")
+ .HasColumnName("email");
+
+ b.Property("Issuer")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("issuer");
+
+ b.Property("KdfAlgorithm")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("kdf_algorithm");
+
+ b.Property("KdfMemoryKibibytes")
+ .HasColumnType("INTEGER")
+ .HasColumnName("kdf_memory_kibibytes");
+
+ b.Property("KdfParallelism")
+ .HasColumnType("INTEGER")
+ .HasColumnName("kdf_parallelism");
+
+ b.Property("KdfPasses")
+ .HasColumnType("INTEGER")
+ .HasColumnName("kdf_passes");
+
+ b.Property("KdfSalt")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("kdf_salt");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("key_generation");
+
+ b.Property("ServerUrl")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("server_url");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("subject");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT")
+ .HasColumnName("user_id");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Storage/Migrations/20260729080003_InitialCache.cs b/src/DodoSSH.Client.Storage/Migrations/20260729080003_InitialCache.cs
new file mode 100644
index 0000000..22b4c8d
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/Migrations/20260729080003_InitialCache.cs
@@ -0,0 +1,211 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace DodoSSH.Client.Storage.Migrations
+{
+ ///
+ public partial class InitialCache : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "conflict",
+ columns: table => new
+ {
+ id = table.Column(type: "TEXT", nullable: false),
+ vault_id = table.Column(type: "TEXT", nullable: false),
+ entity_type = table.Column(type: "INTEGER", nullable: false),
+ entity_id = table.Column(type: "TEXT", nullable: false),
+ kind = table.Column(type: "INTEGER", nullable: false),
+ detail = table.Column(type: "BLOB", nullable: false),
+ detected_at_utc = table.Column(type: "INTEGER", nullable: false),
+ acknowledged = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_conflict", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "item",
+ columns: table => new
+ {
+ vault_id = table.Column(type: "TEXT", nullable: false),
+ entity_type = table.Column(type: "INTEGER", nullable: false),
+ entity_id = table.Column(type: "TEXT", nullable: false),
+ version = table.Column(type: "INTEGER", nullable: false),
+ change_sequence = table.Column(type: "INTEGER", nullable: false),
+ payload = table.Column(type: "BLOB", nullable: true),
+ wrapped_data_key = table.Column(type: "BLOB", nullable: true),
+ data_key_id = table.Column(type: "TEXT", nullable: true),
+ key_generation = table.Column(type: "INTEGER", nullable: false),
+ aad_version = table.Column(type: "INTEGER", nullable: false),
+ protected_fields = table.Column(type: "BLOB", nullable: true),
+ is_deleted = table.Column(type: "INTEGER", nullable: false),
+ updated_at_utc = table.Column(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(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ operation_id = table.Column(type: "TEXT", nullable: false),
+ vault_id = table.Column(type: "TEXT", nullable: false),
+ entity_type = table.Column(type: "INTEGER", nullable: false),
+ entity_id = table.Column(type: "TEXT", nullable: false),
+ operation = table.Column(type: "INTEGER", nullable: false),
+ expected_version = table.Column(type: "INTEGER", nullable: true),
+ payload = table.Column(type: "BLOB", nullable: true),
+ wrapped_data_key = table.Column(type: "BLOB", nullable: true),
+ data_key_id = table.Column(type: "TEXT", nullable: true),
+ key_generation = table.Column(type: "INTEGER", nullable: false),
+ aad_version = table.Column(type: "INTEGER", nullable: false),
+ protected_fields = table.Column(type: "BLOB", nullable: true),
+ ancestor_version = table.Column(type: "INTEGER", nullable: true),
+ ancestor_payload = table.Column(type: "BLOB", nullable: true),
+ ancestor_wrapped_data_key = table.Column(type: "BLOB", nullable: true),
+ ancestor_data_key_id = table.Column(type: "TEXT", nullable: true),
+ ancestor_key_generation = table.Column(type: "INTEGER", nullable: true),
+ ancestor_aad_version = table.Column(type: "INTEGER", nullable: true),
+ ancestor_protected_fields = table.Column(type: "BLOB", nullable: true),
+ queued_at_utc = table.Column(type: "INTEGER", nullable: false),
+ attempts = table.Column(type: "INTEGER", nullable: false),
+ last_error = table.Column(type: "TEXT", nullable: true),
+ is_parked = table.Column(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(type: "TEXT", nullable: false),
+ cursor = table.Column(type: "TEXT", nullable: true),
+ key_generation = table.Column(type: "INTEGER", nullable: false),
+ last_pulled_at_utc = table.Column(type: "INTEGER", nullable: true),
+ last_pushed_at_utc = table.Column(type: "INTEGER", nullable: true),
+ server_time_skew_ms = table.Column(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(type: "INTEGER", nullable: false),
+ server_url = table.Column(type: "TEXT", nullable: false),
+ user_id = table.Column(type: "TEXT", nullable: false),
+ issuer = table.Column(type: "TEXT", nullable: false),
+ subject = table.Column(type: "TEXT", nullable: false),
+ email = table.Column(type: "TEXT", nullable: true),
+ display_name = table.Column(type: "TEXT", nullable: true),
+ key_generation = table.Column(type: "INTEGER", nullable: false),
+ wrapped_private_key = table.Column(type: "BLOB", nullable: false),
+ kdf_algorithm = table.Column(type: "TEXT", nullable: false),
+ kdf_salt = table.Column(type: "BLOB", nullable: false),
+ kdf_memory_kibibytes = table.Column(type: "INTEGER", nullable: false),
+ kdf_passes = table.Column(type: "INTEGER", nullable: false),
+ kdf_parallelism = table.Column(type: "INTEGER", nullable: false),
+ updated_at_utc = table.Column(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(type: "TEXT", nullable: false),
+ name = table.Column