From 8d2416a602f2939c99423a2d6b112d04e8c34893 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Wed, 29 Jul 2026 10:27:37 +0200 Subject: [PATCH] Add the encrypted local cache and the sync client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new client projects, and the wire-contract fix they needed. DodoSSH.Client.Domain holds the decrypted item model and the three-way merge, with no I/O at all — so the suite that decides whether a credential can be lost runs in milliseconds with nothing to mock. Scalars defer to the server on a genuine clash so every replica resolves the same triple identically and two clients cannot ping-pong; directives merge per name so two people each adding one both keep theirs; the jump chain merges as a whole value because its order is the route. Whatever loses is returned rather than dropped. DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are already ciphertext, so an encrypted file would protect protected bytes at the cost of a native dependency. It keeps the server's state and the outbox in separate tables, which is what preserves the common ancestor a merge needs. One pending operation per item, enforced by a unique index. DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts — a change with no local work pending is plumbed as ciphertext — so a first sync of thousands of items does not run twice as many AEAD operations for nothing. Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The specification has required a per-item data key since crypto.md §3, the columns have existed since the first migration and DshAad.ItemPayload binds the id, but this record had nowhere to put either — so a spec-compliant item could not be transmitted at all. Found by writing the client that has to produce one. Also closes a hole in AadResourceType, which had no value for the HostTag and HostCredential that SyncEntityType has always listed. Four bugs the tests found, not review: - SQLite refuses to order or compare its own DateTimeOffset mapping, and throws at execution rather than model build. Collecting tombstones and listing conflicts are both that shape, so this was a crash waiting for the first user with a deleted host. Timestamps are integers now, by convention so a later field cannot be the one left unconverted. - SQLitePCLRaw 2.1.11, which EF resolves, is covered by GHSA-2m69-gcr7-jv3q. Pinned forward as a family. - Resurrecting content from a remote deletion cleared the original before queueing the copy. Two transactions, so a crash between them lost the work; reversed, and the rescued id is derived from the tombstone so a replay coalesces instead of duplicating. - Several equality assertions went through Shouldly's ShouldBe, which compares IEnumerable element-wise and so tested nothing about the Equals these types exist to provide. Corrected; the falsification that caught it went from 2 failures to 6. The push response's cursor is deliberately ignored. It sits after this client's own writes, so adopting it skips anything another client committed at a lower sequence in the window between a pull and a push — permanently. Re-reading one's own writes is idempotent and costs a page. The Contracts doc that invited the shortcut now says so. 593 tests, up from 448. The delete-versus-edit rules, the ancestor retention, the fresh operation id on coalesce and the cursor safeguard were each verified by breaking them and watching the right test fail. --- .editorconfig | 7 +- Directory.Packages.props | 23 + DodoSSH.slnx | 6 + README.md | 13 +- docs/crypto.md | 18 +- docs/platform-flags.md | 23 + src/DodoSSH.Api/Features/Sync/SyncService.cs | 47 ++ src/DodoSSH.Client.Api/DodoSshApiClient.cs | 27 +- .../DodoSSH.Client.Domain.csproj | 16 + src/DodoSSH.Client.Domain/HostOptions.cs | 221 ++++++++ src/DodoSSH.Client.Domain/HostSecret.cs | 116 ++++ src/DodoSSH.Client.Domain/HostSecretCodec.cs | 211 +++++++ src/DodoSSH.Client.Domain/HostSecretMerge.cs | 178 ++++++ src/DodoSSH.Client.Domain/JumpChain.cs | 108 ++++ src/DodoSSH.Client.Domain/ThreeWayMerge.cs | 272 +++++++++ src/DodoSSH.Client.Domain/packages.lock.json | 19 + src/DodoSSH.Client.Storage/CacheMapping.cs | 73 +++ src/DodoSSH.Client.Storage/CacheRows.cs | 283 ++++++++++ .../ClientCacheContext.cs | 165 ++++++ .../ClientCacheFactory.cs | 133 +++++ src/DodoSSH.Client.Storage/ConflictStore.cs | 142 +++++ .../DodoSSH.Client.Storage.csproj | 34 ++ src/DodoSSH.Client.Storage/ItemStore.cs | 192 +++++++ .../LocalCacheProtector.cs | 122 ++++ .../20260729080003_InitialCache.Designer.cs | 408 ++++++++++++++ .../Migrations/20260729080003_InitialCache.cs | 211 +++++++ .../ClientCacheContextModelSnapshot.cs | 405 ++++++++++++++ src/DodoSSH.Client.Storage/OutboxStore.cs | 403 ++++++++++++++ src/DodoSSH.Client.Storage/StoredTypes.cs | 183 ++++++ src/DodoSSH.Client.Storage/SyncStateStore.cs | 110 ++++ src/DodoSSH.Client.Storage/UnlockStore.cs | 137 +++++ src/DodoSSH.Client.Storage/VaultStore.cs | 113 ++++ src/DodoSSH.Client.Storage/packages.lock.json | 400 +++++++++++++ .../DodoSSH.Client.Sync.csproj | 25 + src/DodoSSH.Client.Sync/HostCipher.cs | 176 ++++++ src/DodoSSH.Client.Sync/HostRepository.cs | 299 ++++++++++ src/DodoSSH.Client.Sync/ItemReconciler.cs | 429 ++++++++++++++ src/DodoSSH.Client.Sync/SyncEngine.cs | 487 ++++++++++++++++ src/DodoSSH.Client.Sync/SyncReport.cs | 196 +++++++ src/DodoSSH.Client.Sync/VaultKeyring.cs | 149 +++++ src/DodoSSH.Client.Sync/packages.lock.json | 257 +++++++++ src/DodoSSH.Contracts/DodoSshJsonContext.cs | 3 + src/DodoSSH.Contracts/EncryptedPayload.cs | 21 + src/DodoSSH.Contracts/PublicAPI.Unshipped.txt | 8 +- src/DodoSSH.Contracts/Sync.cs | 12 +- src/DodoSSH.Crypto/CryptoSpec.cs | 11 + src/DodoSSH.Crypto/DshAad.cs | 26 +- .../IdentityEndpointTests.cs | 2 +- tests/DodoSSH.Api.Tests/SyncEndpointTests.cs | 17 +- .../DodoSshApiClientTests.cs | 4 +- .../DodoSSH.Client.Domain.Tests.csproj | 13 + .../HostFactory.cs | 32 ++ .../HostSecretCodecTests.cs | 188 +++++++ .../HostSecretMergeTests.cs | 215 +++++++ .../ThreeWayMergeTests.cs | 275 +++++++++ .../ValueSemanticsTests.cs | 161 ++++++ .../packages.lock.json | 202 +++++++ .../CacheHarness.cs | 130 +++++ .../CacheStoreTests.cs | 401 ++++++++++++++ .../DodoSSH.Client.Storage.Tests.csproj | 14 + .../OutboxStoreTests.cs | 286 ++++++++++ .../packages.lock.json | 423 ++++++++++++++ .../ConflictMatrixTests.cs | 524 ++++++++++++++++++ .../DodoSSH.Client.Sync.Tests.csproj | 19 + .../FakeVaultServer.cs | 377 +++++++++++++ .../HostCipherTests.cs | 204 +++++++ .../ResurrectionIdTests.cs | 46 ++ .../SyncEngineTests.cs | 168 ++++++ .../DodoSSH.Client.Sync.Tests/SyncHarness.cs | 252 +++++++++ .../packages.lock.json | 447 +++++++++++++++ .../SerializationTests.cs | 23 +- tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs | 2 + 72 files changed, 11313 insertions(+), 30 deletions(-) create mode 100644 src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj create mode 100644 src/DodoSSH.Client.Domain/HostOptions.cs create mode 100644 src/DodoSSH.Client.Domain/HostSecret.cs create mode 100644 src/DodoSSH.Client.Domain/HostSecretCodec.cs create mode 100644 src/DodoSSH.Client.Domain/HostSecretMerge.cs create mode 100644 src/DodoSSH.Client.Domain/JumpChain.cs create mode 100644 src/DodoSSH.Client.Domain/ThreeWayMerge.cs create mode 100644 src/DodoSSH.Client.Domain/packages.lock.json create mode 100644 src/DodoSSH.Client.Storage/CacheMapping.cs create mode 100644 src/DodoSSH.Client.Storage/CacheRows.cs create mode 100644 src/DodoSSH.Client.Storage/ClientCacheContext.cs create mode 100644 src/DodoSSH.Client.Storage/ClientCacheFactory.cs create mode 100644 src/DodoSSH.Client.Storage/ConflictStore.cs create mode 100644 src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj create mode 100644 src/DodoSSH.Client.Storage/ItemStore.cs create mode 100644 src/DodoSSH.Client.Storage/LocalCacheProtector.cs create mode 100644 src/DodoSSH.Client.Storage/Migrations/20260729080003_InitialCache.Designer.cs create mode 100644 src/DodoSSH.Client.Storage/Migrations/20260729080003_InitialCache.cs create mode 100644 src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs create mode 100644 src/DodoSSH.Client.Storage/OutboxStore.cs create mode 100644 src/DodoSSH.Client.Storage/StoredTypes.cs create mode 100644 src/DodoSSH.Client.Storage/SyncStateStore.cs create mode 100644 src/DodoSSH.Client.Storage/UnlockStore.cs create mode 100644 src/DodoSSH.Client.Storage/VaultStore.cs create mode 100644 src/DodoSSH.Client.Storage/packages.lock.json create mode 100644 src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj create mode 100644 src/DodoSSH.Client.Sync/HostCipher.cs create mode 100644 src/DodoSSH.Client.Sync/HostRepository.cs create mode 100644 src/DodoSSH.Client.Sync/ItemReconciler.cs create mode 100644 src/DodoSSH.Client.Sync/SyncEngine.cs create mode 100644 src/DodoSSH.Client.Sync/SyncReport.cs create mode 100644 src/DodoSSH.Client.Sync/VaultKeyring.cs create mode 100644 src/DodoSSH.Client.Sync/packages.lock.json create mode 100644 tests/DodoSSH.Client.Domain.Tests/DodoSSH.Client.Domain.Tests.csproj create mode 100644 tests/DodoSSH.Client.Domain.Tests/HostFactory.cs create mode 100644 tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs create mode 100644 tests/DodoSSH.Client.Domain.Tests/HostSecretMergeTests.cs create mode 100644 tests/DodoSSH.Client.Domain.Tests/ThreeWayMergeTests.cs create mode 100644 tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs create mode 100644 tests/DodoSSH.Client.Domain.Tests/packages.lock.json create mode 100644 tests/DodoSSH.Client.Storage.Tests/CacheHarness.cs create mode 100644 tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs create mode 100644 tests/DodoSSH.Client.Storage.Tests/DodoSSH.Client.Storage.Tests.csproj create mode 100644 tests/DodoSSH.Client.Storage.Tests/OutboxStoreTests.cs create mode 100644 tests/DodoSSH.Client.Storage.Tests/packages.lock.json create mode 100644 tests/DodoSSH.Client.Sync.Tests/ConflictMatrixTests.cs create mode 100644 tests/DodoSSH.Client.Sync.Tests/DodoSSH.Client.Sync.Tests.csproj create mode 100644 tests/DodoSSH.Client.Sync.Tests/FakeVaultServer.cs create mode 100644 tests/DodoSSH.Client.Sync.Tests/HostCipherTests.cs create mode 100644 tests/DodoSSH.Client.Sync.Tests/ResurrectionIdTests.cs create mode 100644 tests/DodoSSH.Client.Sync.Tests/SyncEngineTests.cs create mode 100644 tests/DodoSSH.Client.Sync.Tests/SyncHarness.cs create mode 100644 tests/DodoSSH.Client.Sync.Tests/packages.lock.json 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(type: "TEXT", nullable: false), + is_personal = table.Column(type: "INTEGER", nullable: false), + team_id = table.Column(type: "TEXT", nullable: true), + key_generation = table.Column(type: "INTEGER", nullable: false), + permissions = table.Column(type: "INTEGER", nullable: false), + wrapped_vault_key = table.Column(type: "BLOB", nullable: true), + rekey_required = table.Column(type: "INTEGER", nullable: false), + updated_at_utc = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_vault", x => x.vault_id); + }); + + migrationBuilder.CreateIndex( + name: "ix_conflict_vault_id_acknowledged", + table: "conflict", + columns: new[] { "vault_id", "acknowledged" }); + + migrationBuilder.CreateIndex( + name: "ix_conflict_vault_id_entity_type_entity_id", + table: "conflict", + columns: new[] { "vault_id", "entity_type", "entity_id" }); + + migrationBuilder.CreateIndex( + name: "ix_item_vault_id_change_sequence", + table: "item", + columns: new[] { "vault_id", "change_sequence" }); + + migrationBuilder.CreateIndex( + name: "ix_item_vault_id_entity_type", + table: "item", + columns: new[] { "vault_id", "entity_type" }); + + migrationBuilder.CreateIndex( + name: "ix_outbox_operation_id", + table: "outbox", + column: "operation_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_outbox_vault_id_entity_type_entity_id", + table: "outbox", + columns: new[] { "vault_id", "entity_type", "entity_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_outbox_vault_id_is_parked_sequence", + table: "outbox", + columns: new[] { "vault_id", "is_parked", "sequence" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "conflict"); + + migrationBuilder.DropTable( + name: "item"); + + migrationBuilder.DropTable( + name: "outbox"); + + migrationBuilder.DropTable( + name: "sync_state"); + + migrationBuilder.DropTable( + name: "unlock_material"); + + migrationBuilder.DropTable( + name: "vault"); + } + } +} diff --git a/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs b/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs new file mode 100644 index 0000000..dc2082b --- /dev/null +++ b/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs @@ -0,0 +1,405 @@ +// +using System; +using DodoSSH.Client.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DodoSSH.Client.Storage.Migrations +{ + [DbContext(typeof(ClientCacheContext))] + partial class ClientCacheContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b => + { + b.Property("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/OutboxStore.cs b/src/DodoSSH.Client.Storage/OutboxStore.cs new file mode 100644 index 0000000..65253fa --- /dev/null +++ b/src/DodoSSH.Client.Storage/OutboxStore.cs @@ -0,0 +1,403 @@ +using DodoSSH.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Client.Storage; + +/// A local change to queue. +/// Owning vault. +/// Kind of item. +/// The item. Client-generated UUIDv7, so items can be made offline. +/// Upsert or delete. +/// The version the client believes the server holds; null to create. +/// Ciphertext. Required for an upsert. +/// Plaintext columns the server needs. +/// +/// The version this edit branched from, so a conflict can be merged rather than arbitrated. Null when +/// creating, where there is nothing to have branched from. +/// +public sealed record QueuedChange( + Guid VaultId, + SyncEntityType EntityType, + Guid EntityId, + SyncOperation Operation, + int? ExpectedVersion, + EncryptedPayload? Payload, + SyncPlaintextFields? Fields, + StoredAncestor? Ancestor); + +/// +/// Changes made here that the server has not yet accepted. +/// +/// +/// +/// One row per item, and that is a database constraint rather than a convention. Two queued edits to +/// one item would have to be pushed in order, and the second's expectedVersion is the version +/// the first will produce — which is not known when it is queued. Coalescing sidesteps that instead of +/// managing it, and the row holds a desired end state rather than a delta, so coalescing loses nothing. +/// +/// +/// Why a coalesced row gets a new operation id. The id is the server's exactly-once key. If a +/// push has already gone out and the user edits again, keeping the id would let the server answer +/// Duplicate for an operation whose contents have since changed — silently discarding the newer +/// edit. A fresh id means the newer state is offered on its own terms: if the earlier push did land, +/// the version has moved on, the push comes back Conflict, and the merge resolves it against an +/// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges +/// on the newest state with nothing for the user to arbitrate. +/// +/// +public sealed class OutboxStore( + IDbContextFactory contexts, + LocalCacheProtector protector, + TimeProvider clock) +{ + /// + /// Queues a change the user just made, coalescing into any row already pending for the item. + /// + /// + /// A coalesced row keeps the ancestor and expectedVersion of the row it replaces, because + /// the new state is still a descendant of that same base. Taking the caller's values instead would + /// throw away the common ancestor after the first edit, and with it the ability to merge. + /// + public async Task QueueAsync( + QueuedChange change, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(change); + Validate(change); + + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var row = await FindRowAsync(context, change.VaultId, change.EntityType, change.EntityId, cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + row = new OutboxRow + { + VaultId = change.VaultId, + EntityType = change.EntityType, + EntityId = change.EntityId, + ExpectedVersion = change.ExpectedVersion, + }; + + SetAncestor(row, change.EntityType, change.EntityId, change.Ancestor); + context.Add(row); + } + + row.OperationId = Guid.CreateVersion7(); + row.Operation = change.Operation; + row.QueuedAtUtc = clock.GetUtcNow(); + row.Attempts = 0; + row.LastError = null; + row.IsParked = false; + + SetPayload(row, change.EntityType, change.EntityId, change.Payload, change.Fields); + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return ToPending(row); + } + + /// + /// Replaces a pending operation with the outcome of a merge. + /// + /// + /// + /// Distinct from because the intent is opposite: this does move + /// the ancestor forward, to the server version the merge was performed against. Without that the + /// re-push would conflict against the same base for ever. + /// + /// + /// It also keeps the attempt count, where queueing resets it. That difference is what makes + /// the retry bound real: a row that has conflicted five times needs a person to look at it whether + /// or not each attempt carried a freshly merged payload, whereas a user making a new edit has + /// genuinely started over. + /// + /// + public async Task ReviseAsync( + long sequence, + SyncOperation operation, + int? expectedVersion, + EncryptedPayload? payload, + SyncPlaintextFields? fields, + StoredAncestor? ancestor, + CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var row = await context.Set() + .SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + return null; + } + + row.OperationId = Guid.CreateVersion7(); + row.Operation = operation; + row.ExpectedVersion = expectedVersion; + row.LastError = null; + row.IsParked = false; + + SetPayload(row, row.EntityType, row.EntityId, payload, fields); + SetAncestor(row, row.EntityType, row.EntityId, ancestor); + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return ToPending(row); + } + + /// Reads the next operations to push, oldest first, skipping parked ones. + public async Task> TakeAsync( + Guid vaultId, + int limit, + CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1); + + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var rows = await context.Set() + .AsNoTracking() + .Where(r => r.VaultId == vaultId && !r.IsParked) + .OrderBy(r => r.Sequence) + .Take(limit) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return [.. rows.Select(ToPending)]; + } + + /// Reads the operation pending for one item, if any. + 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 FindRowAsync(context, vaultId, entityType, entityId, cancellationToken) + .ConfigureAwait(false); + + return row is null ? null : ToPending(row); + } + + /// + /// Reads every pending operation for a vault, parked ones included. + /// + /// + /// What the interface needs, as opposed to what the pusher needs. A parked change is still the + /// user's current intent for that item and must be what they see; hiding it because the server + /// refused it would show them the old values and look like their edit was lost. + /// + public async Task> ListAllAsync( + Guid vaultId, + CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var rows = await context.Set() + .AsNoTracking() + .Where(r => r.VaultId == vaultId) + .OrderBy(r => r.Sequence) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return [.. rows.Select(ToPending)]; + } + + /// Reads operations the server refused, which need a person. + public async Task> ListParkedAsync( + Guid vaultId, + CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var rows = await context.Set() + .AsNoTracking() + .Where(r => r.VaultId == vaultId && r.IsParked) + .OrderBy(r => r.Sequence) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return [.. rows.Select(ToPending)]; + } + + /// Records that an operation has been sent, so a repeated failure can be noticed. + public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) => + UpdateAsync(sequence, row => row.Attempts++, cancellationToken); + + /// Removes an operation the server accepted. + public async Task CompleteAsync(long sequence, CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var removed = await context.Set() + .Where(r => r.Sequence == sequence) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + return removed > 0; + } + + /// + /// Stops retrying an operation and records why. + /// + /// + /// For the answers that will not change on retry — the server calling an operation structurally + /// invalid, or the caller no longer having permission. Retrying either would spin forever and, far + /// worse, would block every change queued behind it in a vault the user can still write to. + /// + public Task ParkAsync(long sequence, string reason, CancellationToken cancellationToken) => + UpdateAsync( + sequence, + row => + { + row.IsParked = true; + row.LastError = reason; + }, + cancellationToken); + + /// Records a transient failure without parking the operation. + public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) => + UpdateAsync(sequence, row => row.LastError = reason, cancellationToken); + + private static Task FindRowAsync( + ClientCacheContext context, + Guid vaultId, + SyncEntityType entityType, + Guid entityId, + CancellationToken cancellationToken) => + context.Set() + .SingleOrDefaultAsync( + r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId, + cancellationToken); + + private static void Validate(QueuedChange change) + { + if (change.Operation == SyncOperation.Upsert && change.Payload is null) + { + throw new ArgumentException("An upsert requires a payload.", nameof(change)); + } + + if (change.Operation == SyncOperation.Unspecified) + { + throw new ArgumentException("An operation is required.", nameof(change)); + } + + if (change.EntityId == Guid.Empty) + { + throw new ArgumentException("An entity id is required.", nameof(change)); + } + } + + private async Task UpdateAsync( + long sequence, + Action mutate, + CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var row = await context.Set() + .SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + return; + } + + mutate(row); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private void SetPayload( + OutboxRow row, + SyncEntityType entityType, + Guid entityId, + EncryptedPayload? payload, + SyncPlaintextFields? fields) + { + row.Payload = payload?.Envelope; + row.WrappedDataKey = payload?.WrappedDataKey; + row.DataKeyId = payload?.DataKeyId; + row.KeyGeneration = payload?.KeyGeneration ?? 0; + row.AadVersion = payload?.AadVersion ?? 0; + + row.ProtectedFields = Seal(entityType, entityId, fields); + } + + private void SetAncestor( + OutboxRow row, + SyncEntityType entityType, + Guid entityId, + StoredAncestor? ancestor) + { + row.AncestorVersion = ancestor?.Version; + row.AncestorPayload = ancestor?.Payload.Envelope; + row.AncestorWrappedDataKey = ancestor?.Payload.WrappedDataKey; + row.AncestorDataKeyId = ancestor?.Payload.DataKeyId; + row.AncestorKeyGeneration = ancestor?.Payload.KeyGeneration; + row.AncestorAadVersion = ancestor?.Payload.AadVersion; + + row.AncestorProtectedFields = Seal(entityType, entityId, ancestor?.Fields); + } + + private byte[]? Seal(SyncEntityType entityType, Guid entityId, SyncPlaintextFields? fields) => + fields is null + ? null + : protector.Protect( + AadResourceTypes.For(entityType), entityId, PlaintextFieldsCodec.Encode(fields)); + + private SyncPlaintextFields? Open(SyncEntityType entityType, Guid entityId, byte[]? sealedFields) + { + if (sealedFields is null) + { + return null; + } + + var plaintext = protector.TryUnprotect( + AadResourceTypes.For(entityType), entityId, sealedFields); + + return plaintext is null ? null : PlaintextFieldsCodec.TryDecode(plaintext); + } + + private PendingOperation ToPending(OutboxRow row) + { + var ancestorPayload = CacheMapping.ToAncestorPayload(row); + + var ancestor = ancestorPayload is null || row.AncestorVersion is null + ? null + : new StoredAncestor( + row.AncestorVersion.Value, + ancestorPayload, + Open(row.EntityType, row.EntityId, row.AncestorProtectedFields)); + + return new PendingOperation( + row.Sequence, + row.OperationId, + row.VaultId, + row.EntityType, + row.EntityId, + row.Operation, + row.ExpectedVersion, + CacheMapping.ToPayload( + row.Payload, row.WrappedDataKey, row.DataKeyId, row.KeyGeneration, row.AadVersion), + Open(row.EntityType, row.EntityId, row.ProtectedFields), + ancestor, + row.QueuedAtUtc, + row.Attempts, + row.LastError, + row.IsParked); + } +} diff --git a/src/DodoSSH.Client.Storage/StoredTypes.cs b/src/DodoSSH.Client.Storage/StoredTypes.cs new file mode 100644 index 0000000..fc55d8b --- /dev/null +++ b/src/DodoSSH.Client.Storage/StoredTypes.cs @@ -0,0 +1,183 @@ +using DodoSSH.Contracts; + +namespace DodoSSH.Client.Storage; + +/// Why a conflict record exists. +/// +/// Persisted, so append only. These are the vocabulary the UI reasons about: each one implies a +/// different remedy, which is why they are distinguished rather than collapsed into "conflict". +/// +public enum ConflictKind +{ + /// Not a legal value. + Unspecified = 0, + + /// Both sides changed a field. One value survived; the other is in the detail. + FieldOverridden = 1, + + /// This machine deleted an item that someone else edited. The edit won. + LocalDeleteOverridden = 2, + + /// + /// Someone else deleted an item this machine had edited. The local content was preserved under a + /// new id rather than being lost to the tombstone. + /// + RemoteDeleteResurrected = 3, + + /// + /// A payload failed its authentication tag or its schema. Either a client bug or a server that + /// handed back the wrong bytes; both need a human. + /// + Undecryptable = 4, + + /// Written by a newer client than this one, so it is readable but not editable. + TooNewToEdit = 5, + + /// The server refused the operation outright. Retrying will not help. + Rejected = 6, +} + +/// What an offline unlock needs. +/// The server this cache belongs to. +/// The user, which every AAD in the bundle wrap binds to. +/// OIDC issuer. +/// OIDC subject. +/// Email, for display. +/// Display name, for display. +/// Identity key generation. +/// The secret bundle, wrapped under the passphrase-derived key. +/// Parameters needed to re-derive that key. +/// When this was last refreshed from the server. +public sealed record StoredUnlockMaterial( + string ServerUrl, + Guid UserId, + string Issuer, + string Subject, + string? Email, + string? DisplayName, + uint KeyGeneration, + byte[] WrappedPrivateKey, + KdfParameters KdfParameters, + DateTimeOffset UpdatedAt); + +/// A cached vault and the grant that opens it. +/// The vault. +/// Display name. +/// Whether this is the user's personal vault. +/// Owning team, for a team vault. +/// Current key generation. +/// Effective permissions, as a flags value. +/// The vault key sealed to this user. Null while awaiting re-wrap. +/// Whether a membership change has left this vault needing a rekey. +public sealed record StoredVault( + Guid VaultId, + string Name, + bool IsPersonal, + Guid? TeamId, + uint KeyGeneration, + int Permissions, + byte[]? WrappedVaultKey, + bool RekeyRequired); + +/// The last item state the server confirmed. +/// Owning vault. +/// Kind of item. +/// The item. +/// Server-assigned version — what a push must expect. +/// Position in the vault's change log. +/// Ciphertext as the server returned it. Null for a tombstone. +/// The plaintext columns. Null for a tombstone. +/// Whether this is a tombstone. +/// When the change was recorded. +public sealed record StoredItem( + Guid VaultId, + SyncEntityType EntityType, + Guid EntityId, + int Version, + long ChangeSequence, + EncryptedPayload? Payload, + SyncPlaintextFields? Fields, + bool IsDeleted, + DateTimeOffset UpdatedAt); + +/// The item version a pending local edit branched from. +/// +/// Without this a conflict can only be arbitrated, not merged. It is retained verbatim, including the +/// key generation and data key id, because those are part of the payload's AAD and the ancestor cannot +/// be decrypted without them. +/// +/// The version this edit was made against. +/// That version's ciphertext. +/// That version's plaintext columns. +public sealed record StoredAncestor( + int Version, + EncryptedPayload Payload, + SyncPlaintextFields? Fields); + +/// A local change waiting to be pushed. +/// Local ordering. Assigned by the store; ignored on queue. +/// The server's idempotency key for this operation. +/// Owning vault. +/// Kind of item. +/// The item. +/// Upsert or delete. +/// The version the client believes the server holds; null to create. +/// Ciphertext to store. Null for a delete. +/// Plaintext columns. Null for a delete. +/// The version this branched from. Null when creating. +/// When the user made the change. +/// How many times this has been dispatched. +/// Why it last failed. +/// Whether it has been abandoned pending user action. +public sealed record PendingOperation( + long Sequence, + Guid OperationId, + Guid VaultId, + SyncEntityType EntityType, + Guid EntityId, + SyncOperation Operation, + int? ExpectedVersion, + EncryptedPayload? Payload, + SyncPlaintextFields? Fields, + StoredAncestor? Ancestor, + DateTimeOffset QueuedAt, + int Attempts = 0, + string? LastError = null, + bool IsParked = false); + +/// Where a vault's pull has reached. +/// The vault. +/// The last server-issued cursor. Never constructed by a client. +/// The generation the server last reported. +/// When the last pull completed. +/// When the last push completed. +/// Observed clock difference, recorded and never acted on. +public sealed record StoredSyncState( + Guid VaultId, + string? Cursor, + uint KeyGeneration, + DateTimeOffset? LastPulledAt = null, + DateTimeOffset? LastPushedAt = null, + long ServerTimeSkewMs = 0); + +/// Something the merge overrode, or an item that could not be processed. +/// This record's own id, which its sealed detail is bound to. +/// Owning vault. +/// Kind of item. +/// The item. +/// What happened. +/// +/// The discarded values, in plaintext across this boundary and sealed at rest. Opaque to the store: +/// its shape belongs to the sync layer, which owns what a conflict means. +/// +/// When it was noticed. +/// Whether the user has dealt with it. +public sealed record StoredConflict( + Guid Id, + Guid VaultId, + SyncEntityType EntityType, + Guid EntityId, + ConflictKind Kind, + byte[] Detail, + DateTimeOffset DetectedAt, + bool Acknowledged = false); diff --git a/src/DodoSSH.Client.Storage/SyncStateStore.cs b/src/DodoSSH.Client.Storage/SyncStateStore.cs new file mode 100644 index 0000000..9238644 --- /dev/null +++ b/src/DodoSSH.Client.Storage/SyncStateStore.cs @@ -0,0 +1,110 @@ +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Client.Storage; + +/// +/// Where each vault's pull has reached. +/// +/// +/// +/// The cursor is stored exactly as the server issued it and is never parsed, constructed or adjusted. +/// It is opaque and integrity-tagged for a reason: a client that could synthesise one could ask to +/// resume from a position the server never granted, and a tampered cursor is rejected rather than +/// silently mis-serving a range. +/// +/// +/// A null cursor means "from the beginning", which is also the recovery path for a cache that has been +/// discarded or that failed to decrypt. Re-pulling from nothing is always safe; guessing a position is +/// not. +/// +/// +public sealed class SyncStateStore(IDbContextFactory contexts) +{ + /// + /// Reads a vault's position, or a fresh one starting from the beginning. + /// + /// + /// Never returns null. An unknown vault is not an error — it is a vault this client has not synced + /// yet — and a caller forced to handle a null here would most likely handle it by starting from the + /// beginning anyway. + /// + public async Task ReadAsync(Guid vaultId, CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var row = await context.Set() + .AsNoTracking() + .SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken) + .ConfigureAwait(false); + + return row is null + ? new StoredSyncState(vaultId, Cursor: null, KeyGeneration: 0) + : new StoredSyncState( + row.VaultId, + row.Cursor, + row.KeyGeneration, + row.LastPulledAtUtc, + row.LastPushedAtUtc, + row.ServerTimeSkewMs); + } + + /// Records a vault's position. + public async Task SaveAsync(StoredSyncState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(state); + + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var row = await context.Set() + .SingleOrDefaultAsync(r => r.VaultId == state.VaultId, cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + row = new SyncStateRow { VaultId = state.VaultId }; + context.Add(row); + } + + row.Cursor = state.Cursor; + row.KeyGeneration = state.KeyGeneration; + row.LastPulledAtUtc = state.LastPulledAt; + row.LastPushedAtUtc = state.LastPushedAt; + row.ServerTimeSkewMs = state.ServerTimeSkewMs; + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Forgets a vault's position so the next pull starts over. + /// + /// + /// + /// The remedy when the cache cannot be trusted — a key generation the client has no grant for, or + /// rows that will not decrypt. A full re-pull is cheap next to the alternative of reasoning about + /// which half of the cache is still valid. + /// + /// + /// The outbox is deliberately not cleared. Those rows are the only copy of changes the user + /// made and the server has not accepted; discarding them here would turn a recoverable cache + /// problem into lost work. They re-push against the re-pulled state, conflicting and merging where + /// they must. + /// + /// + public async Task ResetAsync(Guid vaultId, CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + await context.Set() + .Where(r => r.VaultId == vaultId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + await context.Set() + .Where(r => r.VaultId == vaultId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/src/DodoSSH.Client.Storage/UnlockStore.cs b/src/DodoSSH.Client.Storage/UnlockStore.cs new file mode 100644 index 0000000..2122ce4 --- /dev/null +++ b/src/DodoSSH.Client.Storage/UnlockStore.cs @@ -0,0 +1,137 @@ +using DodoSSH.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Client.Storage; + +/// +/// Thrown when the cache belongs to a different account than the one signing in. +/// +/// +/// Loud on purpose. Silently adopting the cache would mix one user's items into another's vault list +/// and, worse, would offer an unlock prompt whose passphrase can never work. +/// +public sealed class CacheIdentityMismatchException : InvalidOperationException +{ + /// Creates the exception. + public CacheIdentityMismatchException(string message) + : base(message) + { + } + + /// Creates the exception. + public CacheIdentityMismatchException() + : base("This cache belongs to a different account.") + { + } + + /// Creates the exception. + public CacheIdentityMismatchException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// The material an offline unlock needs. +/// +/// +/// +/// This store is the reason the client works on a plane. The Argon2id salt and the wrapped secret +/// bundle are cached the moment the server hands them over, so deriving the master key and opening the +/// bundle need no network at all. Fetching either at unlock time would make an offline launch +/// impossible, which is the most common moment a user actually needs their vault. +/// +/// +/// Neither value is a secret. The salt is public by construction and the bundle is ciphertext whose key +/// exists only in the user's head. The master key itself is never written here or anywhere else. +/// +/// +public sealed class UnlockStore(IDbContextFactory contexts, TimeProvider clock) +{ + /// Reads the cached material, or null when this cache has never been enrolled. + public async Task ReadAsync(CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var row = await context.Set() + .AsNoTracking() + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + return row is null ? null : ToStored(row); + } + + /// + /// Writes the material, replacing what is there. + /// + /// + /// The cache already holds a different user. One cache file is one account; see + /// for why multiple accounts are not half-supported here. + /// + public async Task SaveAsync(StoredUnlockMaterial material, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(material); + + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var row = await context.Set() + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + row = new UnlockMaterialRow(); + context.Add(row); + } + else if (row.UserId != material.UserId) + { + throw new CacheIdentityMismatchException( + $"This cache holds user {row.UserId}; refusing to overwrite it with {material.UserId}."); + } + + Apply(row, material, clock.GetUtcNow()); + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private static void Apply( + UnlockMaterialRow row, + StoredUnlockMaterial material, + DateTimeOffset now) + { + row.ServerUrl = material.ServerUrl; + row.UserId = material.UserId; + row.Issuer = material.Issuer; + row.Subject = material.Subject; + row.Email = material.Email; + row.DisplayName = material.DisplayName; + row.KeyGeneration = material.KeyGeneration; + row.WrappedPrivateKey = material.WrappedPrivateKey; + row.KdfAlgorithm = material.KdfParameters.Algorithm; + row.KdfSalt = material.KdfParameters.Salt; + row.KdfMemoryKibibytes = material.KdfParameters.MemoryKibibytes; + row.KdfPasses = material.KdfParameters.Passes; + row.KdfParallelism = material.KdfParameters.Parallelism; + row.UpdatedAtUtc = now; + } + + private static StoredUnlockMaterial ToStored(UnlockMaterialRow row) => + new( + row.ServerUrl, + row.UserId, + row.Issuer, + row.Subject, + row.Email, + row.DisplayName, + row.KeyGeneration, + row.WrappedPrivateKey, + new KdfParameters( + row.KdfAlgorithm, + row.KdfSalt, + row.KdfMemoryKibibytes, + row.KdfPasses, + row.KdfParallelism), + row.UpdatedAtUtc); +} diff --git a/src/DodoSSH.Client.Storage/VaultStore.cs b/src/DodoSSH.Client.Storage/VaultStore.cs new file mode 100644 index 0000000..d730ec9 --- /dev/null +++ b/src/DodoSSH.Client.Storage/VaultStore.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Client.Storage; + +/// +/// The vaults this user can reach, and the grants that open them. +/// +/// +/// Cached for the same reason as the unlock material: without the wrapped vault key on disk, an offline +/// launch could unlock the identity bundle and still not decrypt a single item. Every value here is +/// either public metadata or ciphertext. +/// +public sealed class VaultStore(IDbContextFactory contexts, TimeProvider clock) +{ + /// Reads every known vault. + public async Task> ListAsync(CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var rows = await context.Set() + .AsNoTracking() + .OrderByDescending(row => row.IsPersonal) + .ThenBy(row => row.Name) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return [.. rows.Select(ToStored)]; + } + + /// Reads one vault. + public async Task FindAsync(Guid vaultId, CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var row = await context.Set() + .AsNoTracking() + .SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken) + .ConfigureAwait(false); + + return row is null ? null : ToStored(row); + } + + /// + /// Replaces the cached vault list with what the server reported. + /// + /// + /// + /// Vaults absent from the list are removed, because losing access to a vault is exactly what that + /// absence means and a stale row would offer the user a vault they can no longer sync. + /// + /// + /// Their items are a separate matter and are not touched here. Removing a member does not + /// retroactively erase what they already hold — that is not achievable, which is why offboarding + /// means rotating the SSH credential rather than revoking a grant. Deleting the local rows here + /// would only make the client pretend otherwise. + /// + /// + public async Task ReplaceAllAsync( + IReadOnlyList vaults, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(vaults); + + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var existing = await context.Set() + .ToDictionaryAsync(row => row.VaultId, cancellationToken) + .ConfigureAwait(false); + + var now = clock.GetUtcNow(); + + foreach (var vault in vaults) + { + if (!existing.Remove(vault.VaultId, out var row)) + { + row = new CachedVaultRow { VaultId = vault.VaultId }; + context.Add(row); + } + + Apply(row, vault, now); + } + + context.RemoveRange(existing.Values); + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now) + { + row.Name = vault.Name; + row.IsPersonal = vault.IsPersonal; + row.TeamId = vault.TeamId; + row.KeyGeneration = vault.KeyGeneration; + row.Permissions = vault.Permissions; + row.WrappedVaultKey = vault.WrappedVaultKey; + row.RekeyRequired = vault.RekeyRequired; + row.UpdatedAtUtc = now; + } + + private static StoredVault ToStored(CachedVaultRow row) => + new( + row.VaultId, + row.Name, + row.IsPersonal, + row.TeamId, + row.KeyGeneration, + row.Permissions, + row.WrappedVaultKey, + row.RekeyRequired); +} diff --git a/src/DodoSSH.Client.Storage/packages.lock.json b/src/DodoSSH.Client.Storage/packages.lock.json new file mode 100644 index 0000000..ae0db36 --- /dev/null +++ b/src/DodoSSH.Client.Storage/packages.lock.json @@ -0,0 +1,400 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "EFCore.NamingConventions": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "BsvxiKcy8k4/ijAPitmwKG1mlVsdC2lQtFLP28K2N8PlsGYbqPFOyfJ7p2kWil3gM6xXgQGf8Hz/pJB8ej+Dug==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.10", + "Microsoft.EntityFrameworkCore.Relational": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "dodossh.contracts": { + "type": "Project" + }, + "dodossh.crypto": { + "type": "Project", + "dependencies": { + "NSec.Cryptography": "[26.4.0, )" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "NSec.Cryptography": { + "type": "CentralTransitive", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.12", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.12" + } + }, + "SQLitePCLRaw.core": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.12" + } + } + } + } +} \ No newline at end of file diff --git a/src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj b/src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj new file mode 100644 index 0000000..107a7cb --- /dev/null +++ b/src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Sync/HostCipher.cs b/src/DodoSSH.Client.Sync/HostCipher.cs new file mode 100644 index 0000000..8381feb --- /dev/null +++ b/src/DodoSSH.Client.Sync/HostCipher.cs @@ -0,0 +1,176 @@ +using System.Security.Cryptography; +using DodoSSH.Client.Domain; +using DodoSSH.Contracts; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Sync; + +/// +/// Turns a host into an item payload and back. +/// +/// +/// +/// Every operation binds to the item's identity, its key generation and its version, because +/// that is what DshAad.ItemPayload requires. The version part has a consequence worth stating +/// plainly: a payload must be sealed at the version the server will assign, not the version it is +/// replacing. See . +/// +/// +/// The data key is fresh per call and is zeroed before returning, as is the encoded plaintext. Neither +/// is ever handed to a caller: a data key that escaped this class would be a data key some other layer +/// could forget to clear. +/// +/// +public static class HostCipher +{ + private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Host; + + /// + /// Encrypts a host. + /// + /// The host. Must be valid for storage. + /// The vault key, which the data key is wrapped under. + /// The item id, which the AAD binds. + /// The vault's current key generation. + /// + /// The version this payload will hold once the server accepts it — one more than the version being + /// replaced. Sealing at the version being replaced would produce a payload that authenticates + /// against a row that no longer exists, and the item would read as corrupt from then on. + /// + public static EncryptedPayload Seal( + HostSecret host, + ReadOnlySpan vaultKey, + Guid entityId, + uint keyGeneration, + int itemVersion) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1); + + var plaintext = HostSecretCodec.Encode(host); + var dataKey = ItemKeys.CreateDataKey(); + + try + { + var dataKeyId = Guid.CreateVersion7(); + + var wrappedDataKey = ItemKeys.WrapDataKey( + dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion); + + var envelope = ItemKeys.SealPayload( + dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion); + + return new EncryptedPayload( + envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion); + } + finally + { + CryptographicOperations.ZeroMemory(dataKey); + CryptographicOperations.ZeroMemory(plaintext); + } + } + + /// + /// Decrypts a host. + /// + /// + /// The host and the schema version it was written at, or if the payload does + /// not belong to this item, version or generation, or does not parse. + /// + /// A null is a meaningful outcome, not an error to be thrown past. It is what a server relocating + /// ciphertext between rows looks like from here, and it is also what an ordinary rekey looks like + /// before new grants arrive. The caller distinguishes them by comparing generations; either way one + /// unreadable item must not abort a sync pass and strand every change behind it. + /// + /// + public static HostSecretDocument? TryOpen( + EncryptedPayload payload, + ReadOnlySpan vaultKey, + Guid entityId, + int itemVersion) + { + ArgumentNullException.ThrowIfNull(payload); + + if (itemVersion < 1 || payload.WrappedDataKey.Length == 0) + { + return null; + } + + var dataKey = ItemKeys.TryUnwrapDataKey( + vaultKey, + payload.WrappedDataKey, + Resource, + entityId, + payload.KeyGeneration, + (uint)itemVersion); + + if (dataKey is null) + { + return null; + } + + try + { + var plaintext = ItemKeys.TryOpenPayload( + dataKey, + payload.Envelope, + Resource, + entityId, + payload.DataKeyId, + payload.KeyGeneration, + (uint)itemVersion); + + if (plaintext is null) + { + return null; + } + + try + { + return HostSecretCodec.TryDecode(plaintext, out var document) ? document : null; + } + finally + { + CryptographicOperations.ZeroMemory(plaintext); + } + } + finally + { + CryptographicOperations.ZeroMemory(dataKey); + } + } +} + +/// +/// The one place that decides which item version a payload is sealed at. +/// +/// +/// The payload's AAD binds the item version, so the sealing side has to predict what the server will +/// assign. That prediction is safe because it is checked: the server applies an upsert only when +/// expectedVersion matches, and then increments by exactly one. A mismatch is a conflict, not a +/// silently mis-sealed row. Both the sealing and the opening sides go through here, so they cannot +/// drift — the failure if they did would be an item that encrypts fine and never decrypts again. +/// +internal static class SyncVersions +{ + /// The version an accepted upsert will produce. + /// The version being replaced, or null for a create. + internal static int NextVersion(int? expectedVersion) => (expectedVersion ?? 0) + 1; +} + +/// +/// Derives the plaintext columns the server needs from a host. +/// +/// +/// The single point at which a hostname can leave the encrypted payload, which is the whole reason it +/// is a function rather than something each call site assembles. The address is emitted only when the +/// user has turned the relay on for that host; with relay off, the server learns nothing but that an +/// item exists. See ADR 0004 for why the relay cannot work any other way. +/// +internal static class HostFields +{ + internal static SyncPlaintextFields From(HostSecret host) => + host.RelayEnabled + ? new SyncPlaintextFields(RelayEnabled: true, Hostname: host.Hostname, Port: host.Port) + : new SyncPlaintextFields(); +} diff --git a/src/DodoSSH.Client.Sync/HostRepository.cs b/src/DodoSSH.Client.Sync/HostRepository.cs new file mode 100644 index 0000000..9a69753 --- /dev/null +++ b/src/DodoSSH.Client.Sync/HostRepository.cs @@ -0,0 +1,299 @@ +using DodoSSH.Client.Domain; +using DodoSSH.Client.Storage; +using DodoSSH.Contracts; + +namespace DodoSSH.Client.Sync; + +/// A host as the interface should show it. +/// The item id. +/// The decrypted host. +/// +/// The server version this is based on. Zero for an item that has never been accepted. +/// +/// +/// Whether this reflects a local edit the server has not accepted yet. Worth showing: it is the +/// difference between "saved" and "saved here". +/// +/// +/// Whether the pending change was refused and is waiting on a person, so it will not retry on its own. +/// +/// +/// Whether this host was written by a newer client and so must not be edited here, because re-encoding +/// it would drop fields this build cannot represent. +/// +public sealed record VaultHost( + Guid EntityId, + HostSecret Host, + int Version, + bool HasUnsyncedChanges, + bool IsBlocked, + bool IsReadOnly); + +/// The hosts in a vault, and what could not be read. +/// The readable hosts, newest change last. +/// +/// How many items would not decrypt. Surfaced rather than swallowed: a non-zero count here after a +/// rekey is the signal that new grants are needed. +/// +public sealed record HostListing(IReadOnlyList Hosts, int Unreadable); + +/// +/// Reading and writing hosts, as the interface sees them. +/// +/// +/// +/// The view is the mirror of the server's state with the outbox laid over it, which is what makes the +/// application feel local: an edit appears immediately and a delete disappears immediately, whether or +/// not the network is there. Nothing here talks to the server; the sync engine reconciles later. +/// +/// +/// Writes never touch the mirror. That separation is load-bearing — the mirror is the common ancestor a +/// three-way merge needs, and a repository that updated it on save would destroy the very state that +/// lets a conflict be merged instead of arbitrated. +/// +/// +public sealed class HostRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring) +{ + /// Reads every host the user should see in a vault. + public async Task ListAsync(Guid vaultId, CancellationToken cancellationToken) + { + if (!keyring.TryGet(vaultId, out var vaultKey, out _)) + { + throw new VaultUnreadableException(vaultId); + } + + var mirrored = await items + .ListAsync(vaultId, SyncEntityType.Host, includeDeleted: true, cancellationToken) + .ConfigureAwait(false); + + var pending = await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false); + + var pendingByEntity = pending + .Where(operation => operation.EntityType == SyncEntityType.Host) + .ToDictionary(operation => operation.EntityId); + + var hosts = new List(); + var unreadable = 0; + + foreach (var item in mirrored) + { + if (pendingByEntity.Remove(item.EntityId, out var local)) + { + AddPending(hosts, ref unreadable, vaultKey, local); + continue; + } + + if (item.IsDeleted || item.Payload is null) + { + continue; + } + + var opened = HostCipher.TryOpen(item.Payload, vaultKey.Span, item.EntityId, item.Version); + + if (opened is null) + { + unreadable++; + continue; + } + + hosts.Add(new VaultHost( + item.EntityId, opened.Host, item.Version, false, false, opened.IsReadOnly)); + } + + // Whatever is left has no mirror row yet: items created here and not yet accepted. + foreach (var local in pendingByEntity.Values) + { + AddPending(hosts, ref unreadable, vaultKey, local); + } + + return new HostListing(hosts, unreadable); + } + + /// + /// Adds a host, returning the id it was given. + /// + /// + /// The id is generated here, not by the server, which is what lets a host be created with no network + /// at all — the point of the whole outbox. UUIDv7 so that ids sort by creation time, which keeps + /// index locality reasonable on the server side. + /// + public async Task CreateAsync( + Guid vaultId, + HostSecret host, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(host); + Validate(host); + + var (vaultKey, generation) = Key(vaultId); + var entityId = Guid.CreateVersion7(); + + await outbox.QueueAsync( + new QueuedChange( + vaultId, + SyncEntityType.Host, + entityId, + SyncOperation.Upsert, + ExpectedVersion: null, + HostCipher.Seal(host, vaultKey.Span, entityId, generation, itemVersion: 1), + HostFields.From(host), + Ancestor: null), + cancellationToken).ConfigureAwait(false); + + return entityId; + } + + /// + /// Replaces a host's contents. + /// + /// + /// The base is taken from the pending operation when there is one, and from the mirror otherwise. + /// Reading it the other way round would seal the payload at a version that does not match the + /// expectedVersion the coalesced row keeps — and because the AAD binds the item version, the + /// result would encrypt cleanly and never decrypt again. + /// + public async Task UpdateAsync( + Guid vaultId, + Guid entityId, + HostSecret host, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(host); + Validate(host); + + var (vaultKey, generation) = Key(vaultId); + + var pending = await outbox + .FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken) + .ConfigureAwait(false); + + var expectedVersion = pending is not null + ? pending.ExpectedVersion + : await MirrorVersionAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false); + + var ancestor = pending?.Ancestor + ?? await MirrorAncestorAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false); + + await outbox.QueueAsync( + new QueuedChange( + vaultId, + SyncEntityType.Host, + entityId, + SyncOperation.Upsert, + expectedVersion, + HostCipher.Seal( + host, vaultKey.Span, entityId, generation, SyncVersions.NextVersion(expectedVersion)), + HostFields.From(host), + ancestor), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes a host. + /// + /// + /// Queued as a tombstone, never a local removal. An offline client that simply forgot the row would + /// be unable to tell the server anything, and the item would come back on the next pull. + /// + public async Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) + { + var pending = await outbox + .FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken) + .ConfigureAwait(false); + + var expectedVersion = pending is not null + ? pending.ExpectedVersion + : await MirrorVersionAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false); + + var ancestor = pending?.Ancestor + ?? await MirrorAncestorAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false); + + await outbox.QueueAsync( + new QueuedChange( + vaultId, + SyncEntityType.Host, + entityId, + SyncOperation.Delete, + expectedVersion, + Payload: null, + Fields: null, + ancestor), + cancellationToken).ConfigureAwait(false); + } + + private static void Validate(HostSecret host) + { + if (!host.TryValidate(out var error)) + { + throw new ArgumentException(error, nameof(host)); + } + } + + private static void AddPending( + List hosts, + ref int unreadable, + ReadOnlyMemory vaultKey, + PendingOperation local) + { + if (local.Operation == SyncOperation.Delete) + { + // Gone as far as this machine is concerned, even before the server agrees. + return; + } + + if (local.Payload is null) + { + unreadable++; + return; + } + + var version = SyncVersions.NextVersion(local.ExpectedVersion); + var opened = HostCipher.TryOpen(local.Payload, vaultKey.Span, local.EntityId, version); + + if (opened is null) + { + unreadable++; + return; + } + + hosts.Add(new VaultHost( + local.EntityId, + opened.Host, + local.ExpectedVersion ?? 0, + HasUnsyncedChanges: true, + local.IsParked, + opened.IsReadOnly)); + } + + private (ReadOnlyMemory VaultKey, uint Generation) Key(Guid vaultId) => + keyring.TryGet(vaultId, out var vaultKey, out var generation) + ? (vaultKey, generation) + : throw new VaultUnreadableException(vaultId); + + private async Task MirrorVersionAsync( + Guid vaultId, + Guid entityId, + CancellationToken cancellationToken) + { + var item = await items + .FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken) + .ConfigureAwait(false); + + // A null means the server has never seen this item, which is exactly what "create" is. + return item?.Version; + } + + private async Task MirrorAncestorAsync( + Guid vaultId, + Guid entityId, + CancellationToken cancellationToken) + { + var item = await items + .FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken) + .ConfigureAwait(false); + + return item?.Payload is null + ? null + : new StoredAncestor(item.Version, item.Payload, item.Fields); + } +} diff --git a/src/DodoSSH.Client.Sync/ItemReconciler.cs b/src/DodoSSH.Client.Sync/ItemReconciler.cs new file mode 100644 index 0000000..baaaae1 --- /dev/null +++ b/src/DodoSSH.Client.Sync/ItemReconciler.cs @@ -0,0 +1,429 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using DodoSSH.Client.Domain; +using DodoSSH.Client.Storage; +using DodoSSH.Contracts; + +namespace DodoSSH.Client.Sync; + +/// +/// Derives the id a resurrected item takes. +/// +/// +/// Deterministic, from the original id and the version of the tombstone that displaced it. That matters +/// because applying a pulled change is at-least-once: the cursor is saved after the changes are applied, +/// so a process that dies in between re-applies them on the next start. A random id would resurrect the +/// same host twice and leave the user with duplicates to sort out; this way the second attempt produces +/// the same id and coalesces into the same outbox row. +/// +/// Not a UUIDv7, and that is fine — the server treats item ids as opaque, and the time ordering a v7 id +/// carries is meaningless for a copy created to rescue content from a deletion. +/// +/// +internal static class ResurrectionId +{ + internal static Guid For(Guid entityId, int tombstoneVersion) + { + Span input = stackalloc byte[19 + 16 + sizeof(int)]; + + "dsh1/resurrect/v1"u8.CopyTo(input); + var offset = 17; + + input[offset++] = 0; + input[offset++] = 0; + + if (!entityId.TryWriteBytes(input[offset..], bigEndian: true, out _)) + { + throw new InvalidOperationException("Failed to write the entity id."); + } + + offset += 16; + BinaryPrimitives.WriteInt32BigEndian(input[offset..], tombstoneVersion); + + Span digest = stackalloc byte[32]; + SHA256.HashData(input, digest); + + return new Guid(digest[..16], bigEndian: true); + } +} + +/// +/// Decides what happens when a remote change collides with an unpushed local one. +/// +/// +/// +/// Shared by the pull and the push paths, because both meet the same six situations and must answer them +/// identically — a pull that merged one way and a push that merged the other would make the outcome +/// depend on which side happened to notice first. +/// +/// +/// The governing rule is that nothing is discarded silently. Where the two sides can be +/// reconciled field by field, they are. Where they cannot, one value survives, the other is written to +/// the conflict log verbatim, and the user is told. Where a deletion meets an edit, the edit survives: +/// re-deleting costs a click, while a discarded edit may be the only copy of something the user cannot +/// reconstruct. +/// +/// +internal sealed class ItemReconciler( + ItemStore items, + OutboxStore outbox, + ConflictStore conflicts, + VaultKeyring keyring) +{ + /// Reconciles a remote change against the operation pending for the same item. + internal Task ReconcileAsync( + Guid vaultId, + SyncChange remote, + PendingOperation pending, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + if (remote.Operation == SyncOperation.Delete) + { + return pending.Operation == SyncOperation.Delete + // Both sides deleted it. Nothing to arbitrate and nothing to tell the user. + ? outbox.CompleteAsync(pending.Sequence, cancellationToken) + : ResurrectAsync(vaultId, remote, pending, report, cancellationToken); + } + + return pending.Operation == SyncOperation.Delete + ? AbandonLocalDeleteAsync(vaultId, remote, pending, report, cancellationToken) + : MergeAsync(vaultId, remote, pending, report, cancellationToken); + } + + /// + /// Reconciles a pending create that the server says already exists. + /// + /// + /// In practice this means an earlier push of the same create did land and its acknowledgement was + /// lost — a timeout, a dropped connection — after which the local row may also have been edited. The + /// resolution adopts the server's row as the base and re-offers the local content as an update, so + /// the newer local state wins and no duplicate host appears. A genuine id collision between two + /// clients is the other reading, and is not achievable with UUIDv7; if it happened, the server's + /// values would be in the conflict log rather than gone. + /// + internal async Task AdoptRemoteAsBaseAsync( + Guid vaultId, + SyncChange remote, + PendingOperation pending, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken) + .ConfigureAwait(false); + + if (opened is null) + { + return; + } + + var (local, remoteHost, vaultKey, generation) = opened.Value; + + if (local == remoteHost) + { + // Byte-for-byte the same host: this is our own create coming back. Nothing to do but stop + // trying to send it again. + await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); + return; + } + + await ReviseAsUpdateAsync( + vaultId, remote, pending, local, vaultKey, generation, cancellationToken) + .ConfigureAwait(false); + + await conflicts.RecordAsync( + vaultId, + SyncEntityType.Host, + remote.EntityId, + ConflictKind.FieldOverridden, + ConflictDetailCodec.Encode( + $"An item with this id already existed on the server at version {remote.Version}. " + + "The version from this machine was kept; the server's values are recorded here."), + cancellationToken).ConfigureAwait(false); + + report.Merged++; + } + + /// Merges two divergent edits of the same item. + private async Task MergeAsync( + Guid vaultId, + SyncChange remote, + PendingOperation pending, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + if (pending.Ancestor is null) + { + await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken) + .ConfigureAwait(false); + return; + } + + var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken) + .ConfigureAwait(false); + + if (opened is null) + { + return; + } + + var (local, remoteHost, vaultKey, generation) = opened.Value; + + var ancestor = HostCipher.TryOpen( + pending.Ancestor.Payload, vaultKey.Span, remote.EntityId, pending.Ancestor.Version); + + if (ancestor is null) + { + // The base is unreadable, so a three-way merge is not possible. Falling back to a two-way + // one would have to guess which side changed what, so the honest move is to keep the local + // state as an update over the server's and record what was overridden. + await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken) + .ConfigureAwait(false); + return; + } + + var merged = HostSecretMerge.Merge(ancestor.Host, local, remoteHost); + + await ReviseAsUpdateAsync( + vaultId, remote, pending, merged.Merged, vaultKey, generation, cancellationToken) + .ConfigureAwait(false); + + if (merged.HasConflicts) + { + await conflicts.RecordAsync( + vaultId, + SyncEntityType.Host, + remote.EntityId, + ConflictKind.FieldOverridden, + ConflictDetailCodec.Encode( + $"'{merged.Merged.Label}' was edited in two places at once. " + + $"{merged.Conflicts.Count} field(s) could not be reconciled automatically.", + merged.Conflicts), + cancellationToken).ConfigureAwait(false); + } + + report.Merged++; + } + + /// + /// Keeps local content that a remote deletion would otherwise take with it. + /// + /// + /// The tombstone is accepted — arguing with it would conflict for ever, since a delete beats a late + /// upsert on the server — and the local content is re-offered under a fresh id, labelled so the user + /// can see what happened. That is the whole of "never silently drop a host": the original goes, the + /// work does not. + /// + private async Task ResurrectAsync( + Guid vaultId, + SyncChange remote, + PendingOperation pending, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)) + { + await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken) + .ConfigureAwait(false); + return; + } + + var local = pending.Payload is null + ? null + : HostCipher.TryOpen( + pending.Payload, + vaultKey.Span, + remote.EntityId, + SyncVersions.NextVersion(pending.ExpectedVersion)); + + if (local is null || local.IsReadOnly) + { + await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken) + .ConfigureAwait(false); + return; + } + + var restoredId = ResurrectionId.For(remote.EntityId, remote.Version); + var restored = local.Host with { Label = $"{local.Host.Label} (restored)" }; + + // Queued before the original is cleared, and that order matters. These are two separate + // transactions, so a process that dies between them has to fail in the direction that keeps the + // work: this way the original stays pending and the next pass resurrects again — landing on the + // same deterministic id, which coalesces into the row already queued. The other order would + // leave the tombstone accepted and the local content gone. + await outbox.QueueAsync( + new QueuedChange( + vaultId, + SyncEntityType.Host, + restoredId, + SyncOperation.Upsert, + ExpectedVersion: null, + HostCipher.Seal(restored, vaultKey.Span, restoredId, generation, itemVersion: 1), + HostFields.From(restored), + Ancestor: null), + cancellationToken).ConfigureAwait(false); + + // Now the tombstone can stand. + await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); + + await conflicts.RecordAsync( + vaultId, + SyncEntityType.Host, + remote.EntityId, + ConflictKind.RemoteDeleteResurrected, + ConflictDetailCodec.Encode( + $"'{local.Host.Label}' was deleted elsewhere while this machine had unsaved changes. " + + $"The deletion stands and the local version was kept as '{restored.Label}'."), + cancellationToken).ConfigureAwait(false); + + report.Resurrected++; + } + + /// Drops a local deletion because the other side edited the item instead. + private async Task AbandonLocalDeleteAsync( + Guid vaultId, + SyncChange remote, + PendingOperation pending, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); + + await conflicts.RecordAsync( + vaultId, + SyncEntityType.Host, + remote.EntityId, + ConflictKind.LocalDeleteOverridden, + ConflictDetailCodec.Encode( + "This host was edited elsewhere after it was deleted here, so the deletion was not " + + "applied. Delete it again if that is still what you want."), + cancellationToken).ConfigureAwait(false); + + report.DeletesAbandoned++; + } + + /// Re-offers a host as an update against the server's current version. + private async Task ReviseAsUpdateAsync( + Guid vaultId, + SyncChange remote, + PendingOperation pending, + HostSecret host, + ReadOnlyMemory vaultKey, + uint generation, + CancellationToken cancellationToken) + { + var nextVersion = SyncVersions.NextVersion(remote.Version); + + await outbox.ReviseAsync( + pending.Sequence, + SyncOperation.Upsert, + expectedVersion: remote.Version, + HostCipher.Seal(host, vaultKey.Span, remote.EntityId, generation, nextVersion), + HostFields.From(host), + new StoredAncestor(remote.Version, remote.Payload!, remote.PlaintextFields), + cancellationToken).ConfigureAwait(false); + } + + /// Opens both sides of a collision, parking the operation if either will not open. + private async Task<(HostSecret Local, HostSecret Remote, ReadOnlyMemory VaultKey, uint Generation)?> + OpenPairAsync( + Guid vaultId, + SyncChange remote, + PendingOperation pending, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + if (!keyring.TryGet(vaultId, out var vaultKey, out var generation) + || pending.Payload is null + || remote.Payload is null) + { + await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken) + .ConfigureAwait(false); + return null; + } + + var local = HostCipher.TryOpen( + pending.Payload, + vaultKey.Span, + remote.EntityId, + SyncVersions.NextVersion(pending.ExpectedVersion)); + + var remoteHost = HostCipher.TryOpen( + remote.Payload, vaultKey.Span, remote.EntityId, remote.Version); + + if (local is null || remoteHost is null) + { + await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken) + .ConfigureAwait(false); + return null; + } + + if (local.IsReadOnly || remoteHost.IsReadOnly) + { + // A newer client wrote fields this build cannot represent. Re-encoding would drop them, so + // the item is left alone until this client is updated. + await outbox.ParkAsync( + pending.Sequence, + "Written by a newer version of DodoSSH; update before editing this host.", + cancellationToken).ConfigureAwait(false); + + await conflicts.RecordAsync( + vaultId, + SyncEntityType.Host, + remote.EntityId, + ConflictKind.TooNewToEdit, + ConflictDetailCodec.Encode( + "This host was written by a newer version of DodoSSH. It can be read but not " + + "merged here, because saving it would discard fields this version does not know " + + "about."), + cancellationToken).ConfigureAwait(false); + + report.Parked++; + return null; + } + + return (local.Host, remoteHost.Host, vaultKey, generation); + } + + private async Task ParkAsync( + Guid vaultId, + Guid entityId, + PendingOperation pending, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + await outbox.ParkAsync( + pending.Sequence, + "The local or the server copy of this host could not be decrypted.", + cancellationToken).ConfigureAwait(false); + + await conflicts.RecordAsync( + vaultId, + SyncEntityType.Host, + entityId, + ConflictKind.Undecryptable, + ConflictDetailCodec.Encode( + "This host could not be decrypted, so the change made here could not be merged. " + + "The vault key may have been rotated, or the stored payload may not belong to this " + + "item."), + cancellationToken).ConfigureAwait(false); + + report.Unreadable++; + report.Parked++; + } + + /// Writes the server's version of an item into the local mirror. + internal Task MirrorAsync(Guid vaultId, SyncChange change, CancellationToken cancellationToken) => + items.SaveAsync( + new StoredItem( + vaultId, + change.EntityType, + change.EntityId, + change.Version, + change.ChangeSequence, + change.Payload, + change.PlaintextFields, + change.Operation == SyncOperation.Delete, + change.UpdatedAt), + cancellationToken); +} diff --git a/src/DodoSSH.Client.Sync/SyncEngine.cs b/src/DodoSSH.Client.Sync/SyncEngine.cs new file mode 100644 index 0000000..89b9b1c --- /dev/null +++ b/src/DodoSSH.Client.Sync/SyncEngine.cs @@ -0,0 +1,487 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using DodoSSH.Client.Api; +using DodoSSH.Client.Storage; +using DodoSSH.Contracts; + +namespace DodoSSH.Client.Sync; + +/// +/// One vault's synchronisation pass: pull, reconcile, push, pull again. +/// +/// +/// +/// Pull first, so a local change is merged against the newest server state before it is offered — which +/// turns most would-be conflicts into ordinary merges and keeps the push round count down. Push second. +/// Pull once more at the end only if something was pushed, so the mirror reflects the versions the +/// server actually assigned. +/// +/// +/// Pulling does not decrypt. A change with no local work pending is copied into the mirror as +/// ciphertext and nothing more. Decryption happens when a merge needs it, or when the interface reads an +/// item. For a five-thousand-item first sync that is the difference between plumbing bytes and running +/// ten thousand AEAD operations for nothing. +/// +/// +public sealed class SyncEngine +{ + private readonly ISyncApi api; + private readonly ItemStore items; + private readonly OutboxStore outbox; + private readonly SyncStateStore syncState; + private readonly ConflictStore conflicts; + private readonly VaultKeyring keyring; + private readonly TimeProvider clock; + private readonly SyncOptions options; + private readonly ItemReconciler reconciler; + + /// Creates the engine. + public SyncEngine( + ISyncApi api, + ItemStore items, + OutboxStore outbox, + SyncStateStore syncState, + ConflictStore conflicts, + VaultKeyring keyring, + TimeProvider clock, + SyncOptions? options = null) + { + ArgumentNullException.ThrowIfNull(api); + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(outbox); + ArgumentNullException.ThrowIfNull(syncState); + ArgumentNullException.ThrowIfNull(conflicts); + ArgumentNullException.ThrowIfNull(keyring); + ArgumentNullException.ThrowIfNull(clock); + + this.api = api; + this.items = items; + this.outbox = outbox; + this.syncState = syncState; + this.conflicts = conflicts; + this.keyring = keyring; + this.clock = clock; + this.options = options ?? SyncOptions.Default; + + reconciler = new ItemReconciler(items, outbox, conflicts, keyring); + } + + /// Runs a full pass over one vault. + public async Task SyncAsync(Guid vaultId, CancellationToken cancellationToken) + { + var report = new SyncReportBuilder(vaultId); + + await PullAsync(vaultId, report, cancellationToken).ConfigureAwait(false); + + var pushedAnything = false; + + for (var round = 1; ; round++) + { + var outcome = await DrainAsync(vaultId, report, cancellationToken).ConfigureAwait(false); + + if (outcome.Sent == 0) + { + break; + } + + pushedAnything = true; + + if (!outcome.NeedsAnotherRound) + { + break; + } + + if (round >= options.MaxPushRounds) + { + report.RoundsExhausted = true; + break; + } + } + + if (pushedAnything) + { + await PullAsync(vaultId, report, cancellationToken).ConfigureAwait(false); + } + + return report.Build(); + } + + /// + /// Reads every change available and applies it. + /// + /// + /// The cursor is saved after each page's changes are applied, which makes applying at-least-once + /// rather than exactly-once: a process that dies between the two re-reads that page next time. That + /// is deliberate and safe, because applying a change is a blind overwrite of a mirror row and a + /// resurrection takes a deterministic id. The other ordering — save the cursor first — would lose + /// changes outright, which no amount of idempotence can repair. + /// + private async Task PullAsync( + Guid vaultId, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false); + + for (var page = 0; page < options.MaxPullPages; page++) + { + var response = await api.SyncPullAsync( + vaultId, + new SyncPullRequest(state.Cursor, options.PullPageSize, [SyncEntityType.Host]), + cancellationToken).ConfigureAwait(false); + + foreach (var change in response.Changes) + { + await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false); + report.Pulled++; + } + + var advanced = !string.Equals(state.Cursor, response.NextCursor, StringComparison.Ordinal); + + state = Record(vaultId, state, response, report); + await syncState.SaveAsync(state, cancellationToken).ConfigureAwait(false); + + if (!response.HasMore) + { + break; + } + + // A server that claims more but neither returns a change nor moves the cursor would spin + // this loop for ever. Stopping is the only safe reading of that answer. + if (!advanced && response.Changes.Count == 0) + { + break; + } + } + } + + private StoredSyncState Record( + Guid vaultId, + StoredSyncState state, + SyncPullResponse response, + SyncReportBuilder report) + { + var now = clock.GetUtcNow(); + var skew = (long)(response.ServerTime - now).TotalMilliseconds; + + report.ServerKeyGeneration = response.CurrentKeyGeneration; + report.ServerTimeSkewMs = skew; + + // A generation ahead of the key this client holds means the vault was rekeyed and this client's + // grant has not been re-wrapped. Items pulled meanwhile are stored but cannot be read. + keyring.TryGet(vaultId, out _, out var held); + report.RekeyRequired = response.CurrentKeyGeneration > held; + + return state with + { + Cursor = response.NextCursor, + KeyGeneration = response.CurrentKeyGeneration, + LastPulledAt = now, + ServerTimeSkewMs = skew, + }; + } + + private async Task ApplyAsync( + Guid vaultId, + SyncChange change, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + if (change.EntityType != SyncEntityType.Host) + { + // Reserved in the contract but not yet syncable. Ignoring it keeps a newer server's extra + // entity types from breaking an older client's pull. + return; + } + + await reconciler.MirrorAsync(vaultId, change, cancellationToken).ConfigureAwait(false); + + var pending = await outbox + .FindAsync(vaultId, change.EntityType, change.EntityId, cancellationToken) + .ConfigureAwait(false); + + if (pending is null || pending.IsParked) + { + return; + } + + await reconciler.ReconcileAsync(vaultId, change, pending, report, cancellationToken) + .ConfigureAwait(false); + } + + /// What one push round achieved. + [StructLayout(LayoutKind.Auto)] + private readonly record struct DrainOutcome(int Sent, int Conflicts, bool BatchWasFull) + { + internal bool NeedsAnotherRound => Conflicts > 0 || BatchWasFull; + } + + /// + /// Sends one batch and acts on each per-operation answer. + /// + /// + /// The cursor in the push response is deliberately ignored. It sits after this push's own + /// changes, so adopting it would skip any change another client committed at a lower sequence + /// between this client's last pull and this push — permanently. Continuing from the cursor this + /// client already holds re-reads its own writes, which costs one redundant page and is idempotent. + /// + private async Task DrainAsync( + Guid vaultId, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + var pending = await outbox + .TakeAsync(vaultId, options.MaxOperationsPerPush, cancellationToken) + .ConfigureAwait(false); + + if (pending.Count == 0) + { + return default; + } + + var operations = new List(pending.Count); + var byOperationId = new Dictionary(pending.Count); + + foreach (var operation in pending) + { + operations.Add(new SyncPushOperation( + operation.OperationId, + operation.EntityType, + operation.EntityId, + operation.Operation, + operation.ExpectedVersion, + operation.Payload, + operation.Fields)); + + byOperationId[operation.OperationId] = operation; + + await outbox.MarkDispatchedAsync(operation.Sequence, cancellationToken) + .ConfigureAwait(false); + } + + var response = await api + .SyncPushAsync(vaultId, new SyncPushRequest(operations), cancellationToken) + .ConfigureAwait(false); + + var conflicted = 0; + + foreach (var result in response.Results) + { + if (!byOperationId.TryGetValue(result.OperationId, out var operation)) + { + // An id this client did not send. Nothing sane to do with it. + continue; + } + + // The attempt count was incremented above, so the bound is read from the fresh value. + var attempts = operation.Attempts + 1; + + if (await HandleAsync(vaultId, operation with { Attempts = attempts }, result, report, cancellationToken) + .ConfigureAwait(false)) + { + conflicted++; + } + } + + return new DrainOutcome( + pending.Count, conflicted, pending.Count == options.MaxOperationsPerPush); + } + + /// Whether this answer warrants another push round. + private async Task HandleAsync( + Guid vaultId, + PendingOperation operation, + SyncPushResult result, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + switch (result.Status) + { + case SyncOperationStatus.Applied: + case SyncOperationStatus.Duplicate: + // Duplicate means an earlier push of this exact operation id already landed, so the + // stored state is what this operation intended. Treated as success on purpose: that is + // what makes a retry after a timeout exactly-once rather than merely at-least-once. + await AcceptAsync(vaultId, operation, result, cancellationToken).ConfigureAwait(false); + report.Pushed++; + return false; + + case SyncOperationStatus.Conflict: + return await ResolveAsync(vaultId, operation, result, report, cancellationToken) + .ConfigureAwait(false); + + case SyncOperationStatus.Forbidden: + await RejectAsync( + vaultId, + operation, + "You no longer have permission to change this item.", + report, + cancellationToken).ConfigureAwait(false); + return false; + + case SyncOperationStatus.Invalid: + await RejectAsync( + vaultId, + operation, + result.Detail ?? "The server rejected this change as invalid.", + report, + cancellationToken).ConfigureAwait(false); + return false; + + default: + await outbox.RecordFailureAsync( + operation.Sequence, + $"Unexpected push status {result.Status}.", + cancellationToken).ConfigureAwait(false); + return false; + } + } + + /// Records an accepted operation and clears it from the outbox. + private async Task AcceptAsync( + Guid vaultId, + PendingOperation operation, + SyncPushResult result, + CancellationToken cancellationToken) + { + var expected = SyncVersions.NextVersion(operation.ExpectedVersion); + var version = result.Version ?? expected; + var isDelete = operation.Operation == SyncOperation.Delete; + + // The payload was sealed at the version this client predicted, and the AAD binds that version. + // If the server assigned a different one — which its own version check should make impossible — + // storing the payload would leave a mirror row that never decrypts. Skip the write; the pull at + // the end of the pass brings the authoritative row. + var canMirror = isDelete || version == expected; + + if (canMirror) + { + await items.SaveAsync( + new StoredItem( + vaultId, + operation.EntityType, + operation.EntityId, + version, + result.ChangeSequence ?? 0, + isDelete ? null : operation.Payload, + isDelete ? null : operation.Fields, + isDelete, + clock.GetUtcNow()), + cancellationToken).ConfigureAwait(false); + } + + await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false); + } + + private async Task ResolveAsync( + Guid vaultId, + PendingOperation operation, + SyncPushResult result, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + if (operation.Attempts >= options.MaxAttemptsBeforeParking) + { + await RejectAsync( + vaultId, + operation, + string.Create( + CultureInfo.InvariantCulture, + $"Could not be reconciled after {operation.Attempts} attempts."), + report, + cancellationToken).ConfigureAwait(false); + + return false; + } + + if (result.ServerEntity is null) + { + // The version check failed but the server has no such row. Re-offer it as a create. + return await RetryAsCreateAsync(vaultId, operation, report, cancellationToken) + .ConfigureAwait(false); + } + + await reconciler.MirrorAsync(vaultId, result.ServerEntity, cancellationToken) + .ConfigureAwait(false); + + await reconciler + .ReconcileAsync(vaultId, result.ServerEntity, operation, report, cancellationToken) + .ConfigureAwait(false); + + return true; + } + + private async Task RetryAsCreateAsync( + Guid vaultId, + PendingOperation operation, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + if (operation.Operation == SyncOperation.Delete) + { + // Nothing there to delete, so the intent is already satisfied. + await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false); + return false; + } + + if (!keyring.TryGet(vaultId, out var vaultKey, out var generation) + || operation.Payload is null) + { + await RejectAsync( + vaultId, operation, "This item has no usable vault key.", report, cancellationToken) + .ConfigureAwait(false); + return false; + } + + var local = HostCipher.TryOpen( + operation.Payload, + vaultKey.Span, + operation.EntityId, + SyncVersions.NextVersion(operation.ExpectedVersion)); + + if (local is null) + { + await RejectAsync( + vaultId, + operation, + "The queued change could not be decrypted, so it could not be re-offered.", + report, + cancellationToken).ConfigureAwait(false); + return false; + } + + // Re-sealed at version 1, because that is what the server assigns to a create and the AAD binds + // the version. + await outbox.ReviseAsync( + operation.Sequence, + SyncOperation.Upsert, + expectedVersion: null, + HostCipher.Seal(local.Host, vaultKey.Span, operation.EntityId, generation, itemVersion: 1), + HostFields.From(local.Host), + ancestor: null, + cancellationToken).ConfigureAwait(false); + + return true; + } + + /// Parks an operation the server will never accept, and says why. + private async Task RejectAsync( + Guid vaultId, + PendingOperation operation, + string reason, + SyncReportBuilder report, + CancellationToken cancellationToken) + { + await outbox.ParkAsync(operation.Sequence, reason, cancellationToken).ConfigureAwait(false); + + await conflicts.RecordAsync( + vaultId, + operation.EntityType, + operation.EntityId, + ConflictKind.Rejected, + ConflictDetailCodec.Encode(reason), + cancellationToken).ConfigureAwait(false); + + report.Parked++; + } +} diff --git a/src/DodoSSH.Client.Sync/SyncReport.cs b/src/DodoSSH.Client.Sync/SyncReport.cs new file mode 100644 index 0000000..9cd91b5 --- /dev/null +++ b/src/DodoSSH.Client.Sync/SyncReport.cs @@ -0,0 +1,196 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using DodoSSH.Client.Domain; + +namespace DodoSSH.Client.Sync; + +/// Tuning for a sync pass. +/// +/// The push batch default is well below the server's own cap. A smaller batch means a conflict is +/// discovered and merged sooner, and it bounds how much work one rejected batch delays. +/// +public sealed record SyncOptions +{ + /// Changes requested per pull. The server clamps this. + public int PullPageSize { get; init; } = 500; + + /// Operations sent per push. Must not exceed the server's advertised maximum. + public int MaxOperationsPerPush { get; init; } = 100; + + /// + /// How many push rounds one pass may take. + /// + /// + /// A bound rather than a loop until clear. Each round either applies, parks, or merges and advances + /// a version, so it does terminate — but against a vault someone else is writing to continuously it + /// could keep finding new conflicts, and a sync pass that never returns is worse than one that stops + /// and says so. + /// + public int MaxPushRounds { get; init; } = 8; + + /// + /// How many times an operation may be dispatched before it is parked for a person to look at. + /// + public int MaxAttemptsBeforeParking { get; init; } = 5; + + /// + /// How many pages one pull may read. + /// + /// + /// A backstop against a server that keeps saying there is more. At the default page size this is + /// half a million changes, well past any real vault, so reaching it means something is wrong rather + /// than merely large. + /// + public int MaxPullPages { get; init; } = 1000; + + /// The defaults. + public static SyncOptions Default { get; } = new(); +} + +/// What one sync pass did. +/// The vault. +/// Changes received. +/// Operations the server accepted. +/// Items where local and remote edits were reconciled. +/// +/// Items someone else deleted while this machine had unpushed edits. The tombstone stands and the local +/// content survives under a new id; nothing is discarded. +/// +/// +/// Local deletions dropped because the other side edited the item instead. An edit outlives a removal: +/// re-deleting costs a click, and a discarded edit may be irrecoverable. +/// +/// Operations the server refused, now waiting on a person. +/// Items whose payload would not decrypt. +/// The generation the server reports for this vault. +/// +/// True when the server's generation is ahead of the key this client holds, so items cannot be read +/// until new grants arrive. +/// +/// +/// Difference between the server's clock and this machine's. Recorded, never acted on — the merge uses +/// versions and a retained ancestor, so a skewed clock must not be able to decide which edit wins. +/// +/// +/// True when the push loop hit with work still outstanding. Not +/// a failure: the next pass continues from here. +/// +public sealed record SyncReport( + Guid VaultId, + int Pulled, + int Pushed, + int Merged, + int Resurrected, + int DeletesAbandoned, + int Parked, + int Unreadable, + uint ServerKeyGeneration, + bool RekeyRequired, + long ServerTimeSkewMs, + bool RoundsExhausted) +{ + /// Whether anything happened that a user should be told about. + public bool NeedsAttention => + Resurrected > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired; +} + +/// Accumulates a while a pass runs. +internal sealed class SyncReportBuilder(Guid vaultId) +{ + internal int Pulled { get; set; } + + internal int Pushed { get; set; } + + internal int Merged { get; set; } + + internal int Resurrected { get; set; } + + internal int DeletesAbandoned { get; set; } + + internal int Parked { get; set; } + + internal int Unreadable { get; set; } + + internal uint ServerKeyGeneration { get; set; } + + internal bool RekeyRequired { get; set; } + + internal long ServerTimeSkewMs { get; set; } + + internal bool RoundsExhausted { get; set; } + + internal SyncReport Build() => + new( + vaultId, + Pulled, + Pushed, + Merged, + Resurrected, + DeletesAbandoned, + Parked, + Unreadable, + ServerKeyGeneration, + RekeyRequired, + ServerTimeSkewMs, + RoundsExhausted); +} + +/// The record written to the conflict log when a merge had to override something. +/// Which field, as a path. +/// Whose intent was overridden: Local or Remote. +/// The value that survives. +/// The value that lost. +/// Whether what lost was a deletion rather than a value. +public sealed record ConflictDetailEntry( + string Field, + string DiscardedSide, + string? Kept, + string? Discarded, + bool DiscardedWasRemoval); + +/// A conflict log entry. +/// One line for a person to read. +/// Everything the merge overrode. +public sealed record ConflictDetail(string Summary, IReadOnlyList Fields); + +/// +/// Serialises what a merge discarded, for the conflict log. +/// +/// +/// The bytes crossing into ConflictStore are plaintext vault content and are sealed there under +/// the LocalCacheKey. Deliberately its own format rather than the item payload's: this is local +/// bookkeeping and is never pushed, so it has no compatibility obligation to any other client. +/// +internal static class ConflictDetailCodec +{ + internal static byte[] Encode(string summary, IReadOnlyList conflicts) => + JsonSerializer.SerializeToUtf8Bytes( + new ConflictDetail( + summary, + [.. conflicts.Select(c => new ConflictDetailEntry( + c.Field, + c.DiscardedSide.ToString(), + c.Kept, + c.Discarded, + c.DiscardedWasRemoval))]), + ConflictJsonContext.Default.ConflictDetail); + + internal static byte[] Encode(string summary) => Encode(summary, []); + + /// Reads a detail back, for display. + internal static ConflictDetail? TryDecode(ReadOnlySpan utf8) + { + try + { + return JsonSerializer.Deserialize(utf8, ConflictJsonContext.Default.ConflictDetail); + } + catch (JsonException) + { + return null; + } + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(ConflictDetail))] +internal sealed partial class ConflictJsonContext : JsonSerializerContext; diff --git a/src/DodoSSH.Client.Sync/VaultKeyring.cs b/src/DodoSSH.Client.Sync/VaultKeyring.cs new file mode 100644 index 0000000..0f9a3a4 --- /dev/null +++ b/src/DodoSSH.Client.Sync/VaultKeyring.cs @@ -0,0 +1,149 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using DodoSSH.Client.Storage; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Sync; + +/// +/// The vault keys held for the duration of an unlocked session. +/// +/// +/// +/// One place that holds plaintext vault keys, so there is one place that clears them. Every store and +/// every cipher call borrows a key from here rather than keeping a copy, which is what makes +/// "the keys exist only while unlocked" a property of the code and not of everyone's discipline. +/// +/// +/// A grant that will not open is not an error: it means the vault has been rekeyed and this client's +/// grant has not been re-wrapped yet, or the grant was fabricated. Both leave the vault temporarily +/// unreadable and both are reported rather than thrown, so one bad grant does not take the other vaults +/// down with it. +/// +/// +public sealed class VaultKeyring : IDisposable +{ + private readonly Dictionary keys = []; + private readonly Dictionary generations = []; + private bool disposed; + + private VaultKeyring() + { + } + + /// Vaults whose grant could not be opened, and which are therefore unreadable. + public IReadOnlyList Unopened { get; private set; } = []; + + /// + /// Opens every grant the bundle can. + /// + /// The unlocked identity keys. + /// The cached vault list, each with its wrapped key. + public static VaultKeyring Open(UserSecretBundle bundle, IReadOnlyList vaults) + { + ArgumentNullException.ThrowIfNull(bundle); + ArgumentNullException.ThrowIfNull(vaults); + + var keyring = new VaultKeyring(); + var unopened = new List(); + + try + { + foreach (var vault in vaults) + { + if (vault.WrappedVaultKey is null) + { + // The server said so itself: a grant awaiting re-wrap after a rekey. + unopened.Add(vault.VaultId); + continue; + } + + var key = VaultKeys.TryUnwrap( + bundle.EncryptionKey, + vault.WrappedVaultKey, + vault.VaultId, + vault.KeyGeneration); + + if (key is null) + { + unopened.Add(vault.VaultId); + continue; + } + + keyring.keys[vault.VaultId] = key; + keyring.generations[vault.VaultId] = vault.KeyGeneration; + } + + keyring.Unopened = unopened; + return keyring; + } + catch + { + keyring.Dispose(); + throw; + } + } + + /// + /// Borrows a vault's key. + /// + /// + /// The returned memory is the keyring's own buffer, not a copy, and is zeroed when the keyring is + /// disposed. Callers must not retain it past the operation they borrowed it for. + /// + public bool TryGet(Guid vaultId, out ReadOnlyMemory vaultKey, out uint keyGeneration) + { + ObjectDisposedException.ThrowIf(disposed, this); + + if (keys.TryGetValue(vaultId, out var key)) + { + vaultKey = key; + keyGeneration = generations[vaultId]; + return true; + } + + vaultKey = default; + keyGeneration = 0; + return false; + } + + /// Whether this vault can be read at all. + public bool CanRead(Guid vaultId) => !disposed && keys.ContainsKey(vaultId); + + /// + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + + foreach (var key in keys.Values) + { + CryptographicOperations.ZeroMemory(key); + } + + keys.Clear(); + generations.Clear(); + } +} + +/// Thrown when an operation needs a vault key the keyring does not hold. +/// +/// An exception rather than a silent no-op, because every caller that reaches this point has already +/// been given the chance to check . Continuing without the key would +/// mean writing an item nobody can open. +/// +[SuppressMessage( + "Design", + "CA1032:Implement standard exception constructors", + Justification = "The vault id is required context; a message-only constructor would lose it.")] +public sealed class VaultUnreadableException(Guid vaultId) + : InvalidOperationException( + $"Vault {vaultId} has no usable key. Its grant is missing or awaiting re-wrap after a rekey.") +{ + /// The vault that cannot be read. + public Guid VaultId { get; } = vaultId; +} diff --git a/src/DodoSSH.Client.Sync/packages.lock.json b/src/DodoSSH.Client.Sync/packages.lock.json new file mode 100644 index 0000000..28d70f0 --- /dev/null +++ b/src/DodoSSH.Client.Sync/packages.lock.json @@ -0,0 +1,257 @@ +{ + "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==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.10", + "Microsoft.EntityFrameworkCore.Relational": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "dodossh.client.api": { + "type": "Project", + "dependencies": { + "DodoSSH.Client.Auth": "[1.0.0, )", + "DodoSSH.Contracts": "[1.0.0, )", + "DodoSSH.Crypto": "[1.0.0, )" + } + }, + "dodossh.client.auth": { + "type": "Project" + }, + "dodossh.client.domain": { + "type": "Project" + }, + "dodossh.client.storage": { + "type": "Project", + "dependencies": { + "DodoSSH.Contracts": "[1.0.0, )", + "DodoSSH.Crypto": "[1.0.0, )", + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )" + } + }, + "dodossh.contracts": { + "type": "Project" + }, + "dodossh.crypto": { + "type": "Project", + "dependencies": { + "NSec.Cryptography": "[26.4.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "NSec.Cryptography": { + "type": "CentralTransitive", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.12", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.12" + } + }, + "SQLitePCLRaw.core": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.12" + } + } + } + } +} \ No newline at end of file diff --git a/src/DodoSSH.Contracts/DodoSshJsonContext.cs b/src/DodoSSH.Contracts/DodoSshJsonContext.cs index 01fb835..d7fdd18 100644 --- a/src/DodoSSH.Contracts/DodoSshJsonContext.cs +++ b/src/DodoSSH.Contracts/DodoSshJsonContext.cs @@ -43,6 +43,9 @@ namespace DodoSSH.Contracts; [JsonSerializable(typeof(SyncPushRequest))] [JsonSerializable(typeof(SyncPushResponse))] [JsonSerializable(typeof(SyncChange))] +// Registered in its own right, not only as a member of the sync DTOs: the client's local +// cache seals this record under the LocalCacheKey and needs its type info directly. +[JsonSerializable(typeof(SyncPlaintextFields))] [JsonSerializable(typeof(RelayTicketRequest))] [JsonSerializable(typeof(RelayTicketResponse))] [JsonSerializable(typeof(RelaySessionSummary))] diff --git a/src/DodoSSH.Contracts/EncryptedPayload.cs b/src/DodoSSH.Contracts/EncryptedPayload.cs index 0d166fe..7cb8956 100644 --- a/src/DodoSSH.Contracts/EncryptedPayload.cs +++ b/src/DodoSSH.Contracts/EncryptedPayload.cs @@ -15,11 +15,32 @@ namespace DodoSSH.Contracts; /// rather than transmitted, and because they are what makes a lazy re-encrypt-on-write /// migration possible later. /// +/// +/// Added 2026-07-29: and . The +/// specification has required a per-item data key since §3, the database has carried +/// data_key_wrap and content_key_id since the first migration, and +/// DshAad.ItemPayload binds the data key id — but this record had nowhere to put either, +/// so a spec-compliant item could not actually be transmitted. Found by writing the client that +/// has to produce one. Safe to add now and not later: no payload has ever been stored, and only +/// clients can re-encrypt. +/// /// /// The complete DSH1 envelope, base64 on the wire. +/// +/// The item's data key, wrapped under the vault key. Also a DSH1 envelope, and also opaque. It +/// travels with the payload because a data key belongs to one item version: rotating a +/// vault key re-wraps 32 bytes per item and never rewrites a content blob. +/// +/// +/// Identifies the data key, stored as content_key_id. Part of the payload's AAD, so a +/// server cannot pair one item's envelope with another's key wrap. Reserved as the seam for +/// per-item grants in M5. +/// /// Vault key generation this payload was encrypted under. /// Version of the AAD derivation rule used. public sealed record EncryptedPayload( byte[] Envelope, + byte[] WrappedDataKey, + Guid DataKeyId, uint KeyGeneration, byte AadVersion); diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt index cd09859..989c765 100644 --- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt +++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt @@ -36,13 +36,17 @@ DodoSSH.Contracts.EncryptedPayload DodoSSH.Contracts.EncryptedPayload.$() -> DodoSSH.Contracts.EncryptedPayload! DodoSSH.Contracts.EncryptedPayload.AadVersion.get -> byte DodoSSH.Contracts.EncryptedPayload.AadVersion.init -> void -DodoSSH.Contracts.EncryptedPayload.Deconstruct(out byte[]! Envelope, out uint KeyGeneration, out byte AadVersion) -> void -DodoSSH.Contracts.EncryptedPayload.EncryptedPayload(byte[]! Envelope, uint KeyGeneration, byte AadVersion) -> void +DodoSSH.Contracts.EncryptedPayload.DataKeyId.get -> System.Guid +DodoSSH.Contracts.EncryptedPayload.DataKeyId.init -> void +DodoSSH.Contracts.EncryptedPayload.Deconstruct(out byte[]! Envelope, out byte[]! WrappedDataKey, out System.Guid DataKeyId, out uint KeyGeneration, out byte AadVersion) -> void +DodoSSH.Contracts.EncryptedPayload.EncryptedPayload(byte[]! Envelope, byte[]! WrappedDataKey, System.Guid DataKeyId, uint KeyGeneration, byte AadVersion) -> void DodoSSH.Contracts.EncryptedPayload.Envelope.get -> byte[]! DodoSSH.Contracts.EncryptedPayload.Envelope.init -> void DodoSSH.Contracts.EncryptedPayload.Equals(DodoSSH.Contracts.EncryptedPayload? other) -> bool DodoSSH.Contracts.EncryptedPayload.KeyGeneration.get -> uint DodoSSH.Contracts.EncryptedPayload.KeyGeneration.init -> void +DodoSSH.Contracts.EncryptedPayload.WrappedDataKey.get -> byte[]! +DodoSSH.Contracts.EncryptedPayload.WrappedDataKey.init -> void DodoSSH.Contracts.EnrollmentRequest DodoSSH.Contracts.EnrollmentRequest.$() -> DodoSSH.Contracts.EnrollmentRequest! DodoSSH.Contracts.EnrollmentRequest.Deconstruct(out DodoSSH.Contracts.KeyStatement! Statement, out byte[]! StatementSignature, out string! IdentityProviderToken, out byte[]! WrappedPrivateKey, out DodoSSH.Contracts.KdfParameters! KdfParameters, out byte[]? DevicePublicKey, out byte[]? DeviceWrappedPrivateKey, out byte[]? RecoveryWrappedPrivateKey, out DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters, out DodoSSH.Contracts.PersonalVaultRequest! PersonalVault) -> void diff --git a/src/DodoSSH.Contracts/Sync.cs b/src/DodoSSH.Contracts/Sync.cs index 715145f..ebc6c52 100644 --- a/src/DodoSSH.Contracts/Sync.cs +++ b/src/DodoSSH.Contracts/Sync.cs @@ -91,8 +91,16 @@ public sealed record SyncPushResult( /// Per-operation outcomes for a push. /// One entry per submitted operation, in request order. /// -/// A cursor positioned after every change this push produced, so the client can continue -/// pulling without re-reading its own writes. +/// A cursor positioned after every change this push produced. +/// +/// Adopting this is only safe if the client had already pulled to the log head. The cursor is +/// a sequence position, so if another client committed at sequence 10 while this push took 11, +/// jumping to 11 skips 10 permanently. The per-vault advisory lock guarantees that sequence order +/// matches commit order; it cannot tell this client about a write it never read. A client that +/// keeps its own cursor and re-reads its own writes — which is idempotent, since applying a change +/// is a blind overwrite of a local mirror — is strictly safer, and that is what +/// DodoSSH.Client.Sync does. +/// /// public sealed record SyncPushResponse( IReadOnlyList Results, diff --git a/src/DodoSSH.Crypto/CryptoSpec.cs b/src/DodoSSH.Crypto/CryptoSpec.cs index 3d87a3a..fc17bcd 100644 --- a/src/DodoSSH.Crypto/CryptoSpec.cs +++ b/src/DodoSSH.Crypto/CryptoSpec.cs @@ -147,6 +147,17 @@ public static class CryptoSpec /// A known SSH host key. KnownHostKey = 11, + + // 12 and 13 close a hole rather than adding a feature. Contracts.SyncEntityType has carried + // HostTag and HostCredential since it was frozen, so those items are syncable — but with no + // resource type here, their payloads had nothing to bind an AAD to. Added 2026-07-29, while + // the enum is still append-only and no such item has been stored. + + /// A host-to-tag association. + HostTag = 12, + + /// A host-to-credential association. + HostCredential = 13, } /// HKDF info labels. Domain-separated so one subkey cannot stand in for another. diff --git a/src/DodoSSH.Crypto/DshAad.cs b/src/DodoSSH.Crypto/DshAad.cs index 7eca6bb..0fadd6d 100644 --- a/src/DodoSSH.Crypto/DshAad.cs +++ b/src/DodoSSH.Crypto/DshAad.cs @@ -124,16 +124,30 @@ public static class DshAad itemVersion); /// - /// Binds a record in the client's own on-disk cache. + /// Binds a record in the client's own on-disk cache to the row that holds it. /// /// + /// /// Separate from every server-side purpose so a cache record can never be accepted as vault - /// content, nor the reverse. The cache is local, so the adversary here is another process on - /// the same machine rather than the server. + /// content, nor the reverse. The threat model differs too: the cache is local, so the adversary + /// is a process or a backup with access to the file rather than the server. + /// + /// + /// Changed 2026-07-29 from taking the user id to taking the record's identity. The user + /// was already bound by the key — LocalCacheKey is derived from that user's master key, so + /// another user's record cannot decrypt at all — which left the AAD binding nothing, and a cache + /// record could be moved to a different row of the same user's cache. For plaintext columns like + /// a relay address that is not academic: swapping two rows would point one host's connection at + /// another host's address. No cache has ever been written, so there is nothing to migrate. + /// /// - public static AadDescriptor LocalCache(Guid userId) => + /// What kind of item the record belongs to. + /// The row it belongs to — an item id, or a conflict entry's own id. + public static AadDescriptor LocalCache( + CryptoSpec.AadResourceType resourceType, + Guid recordId) => AadDescriptor.Create( CryptoSpec.AadPurpose.LocalCache, - CryptoSpec.AadResourceType.User, - userId); + resourceType, + recordId); } diff --git a/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs index 181b6eb..6c92636 100644 --- a/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs @@ -287,7 +287,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) Guid.CreateVersion7(), SyncOperation.Upsert, null, - new EncryptedPayload([1, 2, 3, 4], 1, 1), + new EncryptedPayload([1, 2, 3, 4], [5, 6], Guid.CreateVersion7(), 1, 1), new SyncPlaintextFields()), ])); diff --git a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs index 645eeb0..1a9eced 100644 --- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs @@ -455,7 +455,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) Guid.CreateVersion7(), SyncOperation.Upsert, null, - new EncryptedPayload([1, 2, 3], 1, 1), + Payload([1, 2, 3]), new SyncPlaintextFields(RelayEnabled: false, Hostname: "secret.internal", Port: 22)), ])); @@ -479,7 +479,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) Guid.CreateVersion7(), SyncOperation.Upsert, null, - new EncryptedPayload([1, 2, 3], 1, 1), + Payload([1, 2, 3]), new SyncPlaintextFields(RelayEnabled: true)), ])); @@ -505,7 +505,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) entityId, SyncOperation.Upsert, null, - new EncryptedPayload([1, 2, 3], 1, 1), + Payload([1, 2, 3]), new SyncPlaintextFields(RelayEnabled: true, Hostname: "bastion.internal", Port: 22)), ])); @@ -595,7 +595,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) Guid.CreateVersion7(), SyncOperation.Upsert, null, - new EncryptedPayload([1], 1, 1), + Payload([1]), null), ])); @@ -616,6 +616,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture) private static string NewSubject() => $"user-{Guid.CreateVersion7():N}"; + /// + /// A structurally valid payload. The bytes are meaningless on purpose: the server cannot read + /// any of them, and a test that pretended otherwise would be testing the wrong thing. + /// + private static EncryptedPayload Payload(byte[] envelope) => + new(envelope, WrappedDataKey: [0xD, 0xE], DataKeyId: Guid.CreateVersion7(), 1, 1); + private static SyncPushOperation NewOperation(Guid entityId, int? expectedVersion, byte[] envelope) => new( Guid.CreateVersion7(), @@ -623,7 +630,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) entityId, SyncOperation.Upsert, expectedVersion, - new EncryptedPayload(envelope, 1, 1), + Payload(envelope), new SyncPlaintextFields()); private static SyncPushRequest NewCreateBatch() => diff --git a/tests/DodoSSH.Client.Api.Tests/DodoSshApiClientTests.cs b/tests/DodoSSH.Client.Api.Tests/DodoSshApiClientTests.cs index ad1e439..6a11eb3 100644 --- a/tests/DodoSSH.Client.Api.Tests/DodoSshApiClientTests.cs +++ b/tests/DodoSSH.Client.Api.Tests/DodoSshApiClientTests.cs @@ -151,7 +151,7 @@ public sealed class DodoSshApiClientTests : IDisposable Guid.CreateVersion7(), SyncOperation.Upsert, null, - new EncryptedPayload([1, 2, 3], 1, 1), + new EncryptedPayload([1, 2, 3], [7, 7], Guid.CreateVersion7(), 1, 1), new SyncPlaintextFields()), ]); @@ -182,7 +182,7 @@ public sealed class DodoSshApiClientTests : IDisposable SyncOperation.Upsert, Version: 1, ChangeSequence: 5, - Payload: new EncryptedPayload([4, 5, 6], 1, 1), + Payload: new EncryptedPayload([4, 5, 6], [8, 8], Guid.CreateVersion7(), 1, 1), PlaintextFields: new SyncPlaintextFields(RelayEnabled: false), UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)), ], diff --git a/tests/DodoSSH.Client.Domain.Tests/DodoSSH.Client.Domain.Tests.csproj b/tests/DodoSSH.Client.Domain.Tests/DodoSSH.Client.Domain.Tests.csproj new file mode 100644 index 0000000..a550da5 --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/DodoSSH.Client.Domain.Tests.csproj @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs b/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs new file mode 100644 index 0000000..20f0880 --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs @@ -0,0 +1,32 @@ +namespace DodoSSH.Client.Domain.Tests; + +/// Builds hosts for the suites, so each test varies only what it is about. +internal static class HostFactory +{ + internal static Guid Bastion { get; } = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e01"); + + internal static Guid Relay { get; } = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e02"); + + internal static HostSecret Host( + string label = "prod-db", + string hostname = "db.internal", + int port = 22, + string? username = "deploy", + string? notes = null, + Guid[]? jumps = null, + (string Name, string Value)[]? options = null, + bool relayEnabled = false) => + new() + { + Label = label, + Hostname = hostname, + Port = port, + Username = username, + Notes = notes, + JumpHostIds = jumps is null ? JumpChain.Empty : JumpChain.Create(jumps), + Options = options is null + ? HostOptions.Empty + : HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))), + RelayEnabled = relayEnabled, + }; +} diff --git a/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs b/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs new file mode 100644 index 0000000..7c0b3d9 --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs @@ -0,0 +1,188 @@ +using System.Text; +using static DodoSSH.Client.Domain.Tests.HostFactory; + +namespace DodoSSH.Client.Domain.Tests; + +/// +/// The payload encoding. +/// +/// +/// Two properties carry weight here. Determinism, because the sync engine compares to decide whether +/// to push, and a codec that produced different bytes for the same host would make every pass look +/// like a change. And failing closed on anything malformed, because these bytes are decrypted inside +/// a sync pass where an exception would strand every item queued behind the bad one. +/// +public sealed class HostSecretCodecTests +{ + [Fact] + public void AFullHost_RoundTrips() + { + var host = Host( + label: "prod-db", + hostname: "db.internal", + port: 2222, + username: "deploy", + notes: "primary replica", + jumps: [Bastion, Relay], + options: [("ServerAliveInterval", "30"), ("Compression", "yes")]); + + HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.Host.ShouldBe(host); + document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion); + document.IsReadOnly.ShouldBeFalse(); + } + + [Fact] + public void AMinimalHost_RoundTrips() + { + var host = Host(username: null, notes: null); + + HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); + + document!.Host.ShouldBe(host); + document.Host.Username.ShouldBeNull(); + document.Host.Notes.ShouldBeNull(); + } + + [Fact] + public void Encoding_IsDeterministic() + { + var host = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]); + + HostSecretCodec.Encode(host).ShouldBe(HostSecretCodec.Encode(host)); + } + + [Fact] + public void DirectiveOrder_DoesNotAffectTheEncoding() + { + // Two clients that agree on the content must produce the same bytes regardless of the order + // the user happened to type the directives in. + var one = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]); + var other = Host(options: [("ServerAliveInterval", "30"), ("Compression", "yes")]); + + HostSecretCodec.Encode(one).ShouldBe(HostSecretCodec.Encode(other)); + } + + [Fact] + public void APayloadFromANewerSchema_IsReadableButReadOnly() + { + // The forward-compatibility rule. An old client can show the host but must not re-encode it, + // because it has no representation for the newer client's extra fields and would drop them. + var payload = Json(""" + { + "schemaVersion": 99, + "label": "prod-db", + "hostname": "db.internal", + "port": 22, + "unknownFutureField": { "nested": true } + } + """); + + HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); + + document!.Host.Label.ShouldBe("prod-db"); + document.Host.Hostname.ShouldBe("db.internal"); + document.IsReadOnly.ShouldBeTrue(); + } + + [Fact] + public void AnUnknownFieldAtTheCurrentSchema_IsSkippedRatherThanFatal() + { + var payload = Json(""" + { + "schemaVersion": 1, + "label": "prod-db", + "hostname": "db.internal", + "port": 22, + "somethingElse": 5 + } + """); + + HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); + document!.IsReadOnly.ShouldBeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData("not json at all")] + [InlineData("{")] + [InlineData("[]")] + [InlineData("null")] + public void MalformedBytes_ReturnFalseRatherThanThrow(string text) + { + HostSecretCodec.TryDecode(Json(text), out var document).ShouldBeFalse(); + document.ShouldBeNull(); + } + + [Theory] + [InlineData("""{ "schemaVersion": 0, "label": "a", "hostname": "b", "port": 22 }""")] + [InlineData("""{ "schemaVersion": -1, "label": "a", "hostname": "b", "port": 22 }""")] + [InlineData("""{ "schemaVersion": 1, "label": "", "hostname": "b", "port": 22 }""")] + [InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "", "port": 22 }""")] + [InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 0 }""")] + [InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 70000 }""")] + public void AStructurallyInvalidPayload_IsRejected(string json) + { + HostSecretCodec.TryDecode(Json(json), out _).ShouldBeFalse(); + } + + [Fact] + public void DuplicateDirectiveNamesDifferingOnlyInCase_AreRejected() + { + // Fails closed. SSH treats keywords case-insensitively, so this payload has no single + // meaning; guessing which one wins would make two clients disagree about the same bytes. + var payload = Json(""" + { + "schemaVersion": 1, + "label": "prod-db", + "hostname": "db.internal", + "port": 22, + "options": { "Compression": "yes", "compression": "no" } + } + """); + + HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse(); + } + + [Fact] + public void AnEmptyJumpHostId_IsRejected() + { + var payload = Json($$""" + { + "schemaVersion": 1, + "label": "prod-db", + "hostname": "db.internal", + "port": 22, + "jumpHostIds": ["{{Guid.Empty}}"] + } + """); + + HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse(); + } + + [Fact] + public void Encode_RefusesAnInvalidHost() + { + // Throwing rather than returning false, because unlike decoding, this is a caller bug: the + // host came from this process and should have been validated before it got here. + Should.Throw(() => HostSecretCodec.Encode(Host(label: " "))); + Should.Throw(() => HostSecretCodec.Encode(Host(port: 0))); + } + + [Fact] + public void TheEncoding_CarriesNoPlaintextOutsideTheEnvelope() + { + // A reminder of what this codec is for: every one of these values is inside the ciphertext. + // There is no plaintext host label anywhere in the system. + var host = Host(label: "prod-db", notes: "root password in 1Password"); + + var text = Encoding.UTF8.GetString(HostSecretCodec.Encode(host)); + + text.Contains("prod-db", StringComparison.Ordinal).ShouldBeTrue(); + text.Contains("1Password", StringComparison.Ordinal).ShouldBeTrue(); + } + + private static byte[] Json(string text) => Encoding.UTF8.GetBytes(text); +} diff --git a/tests/DodoSSH.Client.Domain.Tests/HostSecretMergeTests.cs b/tests/DodoSSH.Client.Domain.Tests/HostSecretMergeTests.cs new file mode 100644 index 0000000..3429f26 --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/HostSecretMergeTests.cs @@ -0,0 +1,215 @@ +using static DodoSSH.Client.Domain.Tests.HostFactory; + +namespace DodoSSH.Client.Domain.Tests; + +/// +/// Merging a host field by field. +/// +/// +/// The primitives are covered by ; this is about the wiring — that +/// every field is actually routed through a merge, that the collections use the right strategy, and +/// that a conflict names the field precisely enough for a user to act on it. +/// +public sealed class HostSecretMergeTests +{ + [Fact] + public void NeitherSideChanged_ProducesTheSameHostAndNoConflicts() + { + var host = Host(); + + var result = HostSecretMerge.Merge(host, host, host); + + result.Merged.ShouldBe(host); + result.HasConflicts.ShouldBeFalse(); + } + + [Fact] + public void EachSideChangedADifferentField_BothSurvive() + { + // The reason a field-level merge is worth writing at all. + var ancestor = Host(); + var local = ancestor with { Notes = "rotate quarterly" }; + var remote = ancestor with { Username = "postgres" }; + + var result = HostSecretMerge.Merge(ancestor, local, remote); + + result.Merged.Notes.ShouldBe("rotate quarterly"); + result.Merged.Username.ShouldBe("postgres"); + result.HasConflicts.ShouldBeFalse(); + } + + [Fact] + public void EveryScalarField_IsRoutedThroughAMerge() + { + // A field added to HostSecret but forgotten in the merge would silently revert to the remote + // value forever. Changing each one only locally proves each is actually consulted. + var ancestor = Host(); + + var local = ancestor with + { + Label = "prod-db-1", + Hostname = "db1.internal", + Port = 2222, + Username = "admin", + Notes = "primary", + JumpHostIds = JumpChain.Create([Bastion]), + Options = HostOptions.Create([new HostOption("Compression", "yes")]), + RelayEnabled = true, + }; + + var result = HostSecretMerge.Merge(ancestor, local, ancestor); + + result.Merged.ShouldBe(local); + result.HasConflicts.ShouldBeFalse(); + } + + [Fact] + public void AClashingScalar_TakesRemoteAndNamesTheFieldItDiscarded() + { + var ancestor = Host(); + var local = ancestor with { Hostname = "db-mine.internal" }; + var remote = ancestor with { Hostname = "db-theirs.internal" }; + + var result = HostSecretMerge.Merge(ancestor, local, remote); + + result.Merged.Hostname.ShouldBe("db-theirs.internal"); + + var conflict = result.Conflicts.ShouldHaveSingleItem(); + conflict.Field.ShouldBe(nameof(HostSecret.Hostname)); + conflict.Kept.ShouldBe("db-theirs.internal"); + conflict.Discarded.ShouldBe("db-mine.internal"); + conflict.DiscardedSide.ShouldBe(MergeSide.Local); + } + + [Fact] + public void AClashingPort_IsReportedAsANumberNotAsBlank() + { + // Rendering the losing value is the entire point of the conflict record; a non-string field + // that formatted to nothing would leave the user unable to restore it. + var ancestor = Host(port: 22); + var result = HostSecretMerge.Merge(ancestor, ancestor with { Port = 2222 }, ancestor with { Port = 2200 }); + + var conflict = result.Conflicts.ShouldHaveSingleItem(); + conflict.Field.ShouldBe(nameof(HostSecret.Port)); + conflict.Kept.ShouldBe("2200"); + conflict.Discarded.ShouldBe("2222"); + } + + [Fact] + public void AJumpChain_MergesAsAWholeRouteRatherThanAsASet() + { + // Deliberate, and the opposite of how the directives merge. Unioning two chains would + // produce a route neither user configured and would silently change which machine is + // reached through which — so this conflicts instead, and reports the discarded route. + var ancestor = Host(); + var local = ancestor with { JumpHostIds = JumpChain.Create([Bastion]) }; + var remote = ancestor with { JumpHostIds = JumpChain.Create([Relay]) }; + + var result = HostSecretMerge.Merge(ancestor, local, remote); + + result.Merged.JumpHostIds.Equals(JumpChain.Create([Relay])).ShouldBeTrue(); + result.Merged.JumpHostIds.Count.ShouldBe(1); + + var conflict = result.Conflicts.ShouldHaveSingleItem(); + conflict.Field.ShouldBe(nameof(HostSecret.JumpHostIds)); + conflict.Discarded.ShouldNotBeNull(); + conflict.Discarded.ShouldContain(Bastion.ToString()); + } + + [Fact] + public void AReorderedJumpChain_IsAChange() + { + var ancestor = Host(jumps: [Bastion, Relay]); + var local = ancestor with { JumpHostIds = JumpChain.Create([Relay, Bastion]) }; + + var result = HostSecretMerge.Merge(ancestor, local, ancestor); + + result.Merged.JumpHostIds.Equals(JumpChain.Create([Relay, Bastion])).ShouldBeTrue(); + } + + [Fact] + public void Directives_MergePerNameSoBothAdditionsSurvive() + { + var ancestor = Host(); + var local = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "yes")]) }; + var remote = ancestor with + { + Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]), + }; + + var result = HostSecretMerge.Merge(ancestor, local, remote); + + result.Merged.Options.Count.ShouldBe(2); + result.Merged.Options.TryGetValue("Compression", out var compression).ShouldBeTrue(); + compression.ShouldBe("yes"); + result.Merged.Options.TryGetValue("ServerAliveInterval", out var keepAlive).ShouldBeTrue(); + keepAlive.ShouldBe("30"); + result.HasConflicts.ShouldBeFalse(); + } + + [Fact] + public void AClashingDirective_NamesTheDirectiveNotJustTheField() + { + // "Options changed" would be useless. The user needs to know which one. + var ancestor = Host(options: [("Compression", "yes")]); + var local = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "no")]) }; + var remote = ancestor with + { + Options = HostOptions.Create([new HostOption("Compression", "delayed")]), + }; + + var result = HostSecretMerge.Merge(ancestor, local, remote); + + var conflict = result.Conflicts.ShouldHaveSingleItem(); + conflict.Field.ShouldBe("Options[Compression]"); + conflict.Kept.ShouldBe("delayed"); + conflict.Discarded.ShouldBe("no"); + } + + [Fact] + public void ARemovedDirectiveTheOtherSideEdited_KeepsTheValue() + { + var ancestor = Host(options: [("Compression", "yes")]); + var local = ancestor with { Options = HostOptions.Empty }; + var remote = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "no")]) }; + + var result = HostSecretMerge.Merge(ancestor, local, remote); + + result.Merged.Options.TryGetValue("Compression", out var value).ShouldBeTrue(); + value.ShouldBe("no"); + result.Conflicts.ShouldHaveSingleItem().DiscardedWasRemoval.ShouldBeTrue(); + } + + [Fact] + public void TheMergedHost_IsAlwaysValidWhenBothInputsWere() + { + // A merge that produced an unstorable host would strand the item: it could never be pushed + // and the conflict could never clear. + var ancestor = Host(); + var local = ancestor with { Label = "mine", Port = 2222 }; + var remote = ancestor with { Label = "theirs", Hostname = "other.internal" }; + + var result = HostSecretMerge.Merge(ancestor, local, remote); + + result.Merged.TryValidate(out var error).ShouldBeTrue(error); + } + + [Fact] + public void ResolvingAConflictConverges() + { + // Two clients, both merging, must reach the same host and then stop. Re-merging the result + // against the remote produces no further conflict — which is what stops an endless + // push-conflict-merge-push loop between two machines. + var ancestor = Host(); + var local = ancestor with { Notes = "mine", Username = "a" }; + var remote = ancestor with { Notes = "theirs", Hostname = "other.internal" }; + + var first = HostSecretMerge.Merge(ancestor, local, remote); + first.HasConflicts.ShouldBeTrue(); + + var second = HostSecretMerge.Merge(remote, first.Merged, remote); + + second.HasConflicts.ShouldBeFalse(); + second.Merged.ShouldBe(first.Merged); + } +} diff --git a/tests/DodoSSH.Client.Domain.Tests/ThreeWayMergeTests.cs b/tests/DodoSSH.Client.Domain.Tests/ThreeWayMergeTests.cs new file mode 100644 index 0000000..83f5c3c --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/ThreeWayMergeTests.cs @@ -0,0 +1,275 @@ +namespace DodoSSH.Client.Domain.Tests; + +/// +/// The merge primitives. +/// +/// +/// These are the rules the whole sync story rests on, so they are tested as rules rather than +/// through the sync engine: every triple of (ancestor, local, remote) states is enumerated, and each +/// asserts both the surviving value and — where a side lost — that the losing value came back. +/// +public sealed class ThreeWayMergeTests +{ + // ---- Scalar ---- + + [Fact] + public void NeitherSideChanged_IsAgreement() + { + var merge = ThreeWayMerge.Scalar("base", "base", "base"); + + merge.Value.ShouldBe("base"); + merge.Decision.ShouldBe(MergeDecision.Agreed); + merge.IsConflicted.ShouldBeFalse(); + } + + [Fact] + public void OnlyLocalChanged_KeepsTheLocalValue() + { + var merge = ThreeWayMerge.Scalar("base", "mine", "base"); + + merge.Value.ShouldBe("mine"); + merge.Decision.ShouldBe(MergeDecision.TookLocal); + } + + [Fact] + public void OnlyRemoteChanged_KeepsTheRemoteValue() + { + var merge = ThreeWayMerge.Scalar("base", "base", "theirs"); + + merge.Value.ShouldBe("theirs"); + merge.Decision.ShouldBe(MergeDecision.TookRemote); + } + + [Fact] + public void BothSidesMadeTheSameChange_IsAgreementRatherThanAConflict() + { + // Two people fixing the same typo must not be asked to arbitrate. + var merge = ThreeWayMerge.Scalar("base", "fixed", "fixed"); + + merge.Value.ShouldBe("fixed"); + merge.Decision.ShouldBe(MergeDecision.Agreed); + merge.IsConflicted.ShouldBeFalse(); + } + + [Fact] + public void BothSidesChangedDifferently_TakesRemoteAndReportsLocal() + { + // Remote wins so that every replica resolves the same triple identically; without a fixed + // winner two clients each keep their own value and push over each other forever. + var merge = ThreeWayMerge.Scalar("base", "mine", "theirs"); + + merge.Value.ShouldBe("theirs"); + merge.Decision.ShouldBe(MergeDecision.Conflicted); + merge.IsConflicted.ShouldBeTrue(); + + // The whole justification for picking a side: the other one is handed back, never dropped. + merge.Discarded.ShouldBe("mine"); + } + + [Fact] + public void AConflictedMerge_IsIdempotentOnceResolved() + { + // Convergence, spelled out. Having taken the remote value, re-merging against the same + // remote must be agreement rather than a fresh conflict — otherwise the two clients + // ping-pong. + var first = ThreeWayMerge.Scalar("base", "mine", "theirs"); + var second = ThreeWayMerge.Scalar("theirs", first.Value, "theirs"); + + second.Decision.ShouldBe(MergeDecision.Agreed); + second.Value.ShouldBe("theirs"); + } + + [Fact] + public void Scalar_UsesTheSuppliedComparer() + { + // Ordinal by default would call these a conflict; the comparer is how a field opts out. + var merge = ThreeWayMerge.Scalar("base", "SAME", "same", StringComparer.OrdinalIgnoreCase); + + merge.Decision.ShouldBe(MergeDecision.Agreed); + } + + [Fact] + public void Scalar_HandlesNullOnAnySide() + { + // Nullable fields are the common case — Username and Notes are both optional — so a null + // must be an ordinary value here rather than a special case that throws. + ThreeWayMerge.Scalar(null, "set", null).Value.ShouldBe("set"); + ThreeWayMerge.Scalar("was", null, "was").Value.ShouldBeNull(); + ThreeWayMerge.Scalar(null, null, null).Decision.ShouldBe(MergeDecision.Agreed); + } + + // ---- Map ---- + + [Fact] + public void EachSideAddedADifferentKey_KeepsBoth() + { + // The single most visible benefit of a per-key merge over comparing whole collections: two + // people adding different directives to one host both keep theirs. + var merge = Map( + ancestor: [], + local: [("Compression", "yes")], + remote: [("ServerAliveInterval", "30")]); + + merge.Merged.Count.ShouldBe(2); + merge.Merged["Compression"].ShouldBe("yes"); + merge.Merged["ServerAliveInterval"].ShouldBe("30"); + merge.Conflicts.ShouldBeEmpty(); + } + + [Fact] + public void EachSideAddedTheSameKeyDifferently_TakesRemoteAndReportsLocal() + { + var merge = Map( + ancestor: [], + local: [("Port", "2222")], + remote: [("Port", "2200")]); + + merge.Merged["Port"].ShouldBe("2200"); + + var conflict = merge.Conflicts.ShouldHaveSingleItem(); + conflict.Key.ShouldBe("Port"); + conflict.Kept.ShouldBe("2200"); + conflict.Discarded.ShouldBe("2222"); + conflict.DiscardedSide.ShouldBe(MergeSide.Local); + conflict.DiscardedWasRemoval.ShouldBeFalse(); + } + + [Fact] + public void OneSideRemovedAKeyTheOtherLeftAlone_RemovesIt() + { + Map( + ancestor: [("Compression", "yes")], + local: [], + remote: [("Compression", "yes")]) + .Merged.ShouldBeEmpty(); + + Map( + ancestor: [("Compression", "yes")], + local: [("Compression", "yes")], + remote: []) + .Merged.ShouldBeEmpty(); + } + + [Fact] + public void RemoteEditedAKeyLocalRemoved_KeepsTheEditAndReportsTheRemoval() + { + // An edit outlives a removal in both directions. Re-applying a removal costs one click; + // a discarded value may be the only copy of something the user cannot reconstruct. + var merge = Map( + ancestor: [("Compression", "yes")], + local: [], + remote: [("Compression", "no")]); + + merge.Merged["Compression"].ShouldBe("no"); + + var conflict = merge.Conflicts.ShouldHaveSingleItem(); + conflict.DiscardedSide.ShouldBe(MergeSide.Local); + conflict.DiscardedWasRemoval.ShouldBeTrue(); + conflict.Kept.ShouldBe("no"); + } + + [Fact] + public void LocalEditedAKeyRemoteRemoved_KeepsTheEditAndReportsTheRemoval() + { + var merge = Map( + ancestor: [("Compression", "yes")], + local: [("Compression", "no")], + remote: []); + + merge.Merged["Compression"].ShouldBe("no"); + + var conflict = merge.Conflicts.ShouldHaveSingleItem(); + + // The overridden side is the remote one here, which is what makes this asymmetric from the + // scalar rule: the tie-break is "a value beats an absence" before it is "remote wins". + conflict.DiscardedSide.ShouldBe(MergeSide.Remote); + conflict.DiscardedWasRemoval.ShouldBeTrue(); + } + + [Fact] + public void BothSidesRemovedTheSameKey_IsAgreement() + { + var merge = Map( + ancestor: [("Compression", "yes")], + local: [], + remote: []); + + merge.Merged.ShouldBeEmpty(); + merge.Conflicts.ShouldBeEmpty(); + } + + [Fact] + public void UnchangedKeys_SurviveAlongsideConflictingOnes() + { + // A conflict on one key must not disturb its neighbours, which is the difference between + // field-level merge and replacing the collection. + var merge = Map( + ancestor: [("Keep", "same"), ("Fight", "base")], + local: [("Keep", "same"), ("Fight", "mine")], + remote: [("Keep", "same"), ("Fight", "theirs")]); + + merge.Merged["Keep"].ShouldBe("same"); + merge.Merged["Fight"].ShouldBe("theirs"); + merge.Conflicts.ShouldHaveSingleItem().Key.ShouldBe("Fight"); + } + + [Fact] + public void Map_TreatsKeysUnderTheSuppliedComparer() + { + // SSH keywords are case-insensitive. Treating these as two keys would let a host carry + // both Compression and compression, which no client could then reconcile. + var merge = Map( + ancestor: [("Compression", "yes")], + local: [("compression", "yes")], + remote: [("COMPRESSION", "yes")]); + + merge.Merged.Count.ShouldBe(1); + merge.Conflicts.ShouldBeEmpty(); + } + + [Fact] + public void Map_NeverDropsAValueWithoutReportingIt() + { + // The invariant, asserted directly rather than inferred from the cases above: every value + // present on either side either survives into the merge or appears in the conflict list. + var local = new[] { ("A", "1"), ("B", "2"), ("C", "3") }; + var remote = new[] { ("A", "9"), ("B", "2"), ("D", "4") }; + + var merge = Map(ancestor: [("A", "0"), ("B", "2")], local: local, remote: remote); + + foreach (var (key, value) in local.Concat(remote)) + { + var survived = merge.Merged.TryGetValue(key, out var kept) + && string.Equals(kept, value, StringComparison.Ordinal); + + var reported = merge.Conflicts.Any(c => + HostOption.NameComparer.Equals(c.Key, key) + && string.Equals(c.Discarded, value, StringComparison.Ordinal)); + + (survived || reported).ShouldBeTrue($"{key}={value} was neither kept nor reported."); + } + } + + private static MapMerge Map( + (string Key, string Value)[] ancestor, + (string Key, string Value)[] local, + (string Key, string Value)[] remote) => + ThreeWayMerge.Map( + ToMap(ancestor), + ToMap(local), + ToMap(remote), + HostOption.NameComparer, + StringComparer.Ordinal); + + private static Dictionary ToMap((string Key, string Value)[] entries) + { + var map = new Dictionary(HostOption.NameComparer); + + foreach (var (key, value) in entries) + { + map[key] = value; + } + + return map; + } +} diff --git a/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs b/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs new file mode 100644 index 0000000..83880e0 --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs @@ -0,0 +1,161 @@ +using static DodoSSH.Client.Domain.Tests.HostFactory; + +namespace DodoSSH.Client.Domain.Tests; + +/// +/// Equality of the collection types, and of the host that holds them. +/// +/// +/// This suite guards a failure that would be invisible rather than loud. If any of these compared by +/// reference, the merge would report every host as changed on every sync pass, two identical edits +/// would register as a conflict, and the engine would push spurious updates forever. Nothing would +/// throw and no test elsewhere would obviously fail — which is exactly why these are asserted here. +/// +public sealed class ValueSemanticsTests +{ + [Fact] + public void TwoHostsWithEqualContents_AreEqual() + { + var one = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]); + var other = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]); + + one.ShouldBe(other); + one.GetHashCode().ShouldBe(other.GetHashCode()); + } + + [Fact] + public void AHostDifferingOnlyInACollection_IsNotEqual() + { + Host(jumps: [Bastion]).ShouldNotBe(Host(jumps: [Relay])); + Host(options: [("Compression", "yes")]).ShouldNotBe(Host(options: [("Compression", "no")])); + } + + // Note on the assertion style below: these call Equals and the operators directly rather than + // going through ShouldBe. Both of these types implement IReadOnlyList, and Shouldly compares + // enumerables element by element — so ShouldBe would pass whatever Equals did, which is the one + // thing this suite exists to check. + + [Fact] + public void AJumpChain_ComparesByContentsAndOrder() + { + JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeTrue(); + (JumpChain.Create([Bastion, Relay]) == JumpChain.Create([Bastion, Relay])).ShouldBeTrue(); + + JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Relay, Bastion])).ShouldBeFalse(); + JumpChain.Create([Bastion]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeFalse(); + + JumpChain.Create([]).Equals(JumpChain.Empty).ShouldBeTrue(); + JumpChain.Create([Bastion]).Equals(null).ShouldBeFalse(); + } + + [Fact] + public void AJumpChain_HashesByContents() + { + JumpChain.Create([Bastion, Relay]).GetHashCode() + .ShouldBe(JumpChain.Create([Bastion, Relay]).GetHashCode()); + } + + [Fact] + public void AJumpChain_ComparesEqualAcrossTheSpanAndSequenceFactories() + { + Guid[] hops = [Bastion, Relay]; + + JumpChain.Create(hops.AsSpan()).Equals(JumpChain.Create(hops.AsEnumerable())).ShouldBeTrue(); + } + + [Fact] + public void Directives_CompareIgnoringNameCaseAndInputOrder() + { + // Both halves matter. Case, because a merge picks whichever spelling it saw first and two + // clients must still agree. Order, because the collection canonicalises and a user typing + // the same two directives in the other order has not changed anything. + var one = HostOptions.Create([new HostOption("Compression", "yes"), new HostOption("Port", "22")]); + var other = HostOptions.Create([new HostOption("port", "22"), new HostOption("compression", "yes")]); + + one.Equals(other).ShouldBeTrue(); + (one == other).ShouldBeTrue(); + one.GetHashCode().ShouldBe(other.GetHashCode()); + } + + [Fact] + public void Directives_CompareValuesCaseSensitively() + { + // Keywords are case-insensitive in SSH; values are not. "yes" and "YES" happen to mean the + // same to sshd, but this layer must not decide that for every directive that exists. + HostOptions.Create([new HostOption("Compression", "yes")]) + .Equals(HostOptions.Create([new HostOption("Compression", "YES")])) + .ShouldBeFalse(); + } + + [Fact] + public void Directives_CompareUnequalWhenOneSideHasMore() + { + var one = HostOptions.Create([new HostOption("Compression", "yes")]); + var other = HostOptions.Create( + [new HostOption("Compression", "yes"), new HostOption("Port", "22")]); + + one.Equals(other).ShouldBeFalse(); + HostOptions.Empty.Equals(one).ShouldBeFalse(); + one.Equals(null).ShouldBeFalse(); + } + + [Fact] + public void Directives_AreHeldInNameOrder() + { + var options = HostOptions.Create( + [ + new HostOption("ServerAliveInterval", "30"), + new HostOption("Compression", "yes"), + ]); + + options[0].Name.ShouldBe("Compression"); + options[1].Name.ShouldBe("ServerAliveInterval"); + } + + [Fact] + public void ARepeatedDirectiveName_IsRefused() + { + // A repeated keyword has no merge key, so M1 cannot represent it. Refusing is the honest + // answer; silently keeping one of the two would lose data without saying so. + var duplicate = new[] + { + new HostOption("Compression", "yes"), + new HostOption("compression", "no"), + }; + + HostOptions.TryCreate(duplicate, out _, out var error).ShouldBeFalse(); + error.ShouldNotBeNull(); + error.Contains("more than once", StringComparison.Ordinal).ShouldBeTrue(); + + Should.Throw(() => HostOptions.Create(duplicate)); + } + + [Fact] + public void ABlankDirectiveName_IsRefused() + { + HostOptions.TryCreate([new HostOption(" ", "x")], out _, out _).ShouldBeFalse(); + } + + [Fact] + public void AHostBuiltWithWith_KeepsCollectionEquality() + { + // `with` copies the collection references, so this would pass even under reference equality. + // It is here because the merge builds its result with an object initialiser rather than + // `with`, and both paths have to agree. + var host = Host(options: [("Compression", "yes")]); + var copy = host with { Notes = "changed" }; + + copy.Options.Equals(host.Options).ShouldBeTrue(); + (copy with { Notes = host.Notes }).ShouldBe(host); + } + + [Fact] + public void TryValidate_RejectsWhatCannotBeStored() + { + Host(label: "").TryValidate(out _).ShouldBeFalse(); + Host(hostname: " ").TryValidate(out _).ShouldBeFalse(); + Host(port: 65536).TryValidate(out _).ShouldBeFalse(); + Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse(); + Host().TryValidate(out _).ShouldBeTrue(); + } +} diff --git a/tests/DodoSSH.Client.Domain.Tests/packages.lock.json b/tests/DodoSSH.Client.Domain.Tests/packages.lock.json new file mode 100644 index 0000000..5a87715 --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/packages.lock.json @@ -0,0 +1,202 @@ +{ + "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==" + }, + "NSubstitute": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "Shouldly": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==", + "dependencies": { + "DiffEngine": "11.3.0", + "EmptyFiles": "4.4.0" + } + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "DiffEngine": { + "type": "Transitive", + "resolved": "11.3.0", + "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==", + "dependencies": { + "EmptyFiles": "4.4.0", + "System.Management": "6.0.1" + } + }, + "EmptyFiles": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "dodossh.client.domain": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/DodoSSH.Client.Storage.Tests/CacheHarness.cs b/tests/DodoSSH.Client.Storage.Tests/CacheHarness.cs new file mode 100644 index 0000000..d570505 --- /dev/null +++ b/tests/DodoSSH.Client.Storage.Tests/CacheHarness.cs @@ -0,0 +1,130 @@ +using DodoSSH.Contracts; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Storage.Tests; + +/// +/// A migrated, unlocked cache for one test. +/// +/// +/// Each harness gets its own in-memory database, so tests cannot interfere and can run in parallel. +/// The Argon2id cost is deliberately far below the shipped profile — 8 MiB and one pass rather than +/// 256 MiB and four. The stretching is what makes a stolen wrap expensive to attack, and none of these +/// tests attack one; paying 320 ms per test to prove nothing would only encourage sharing state +/// between them. +/// +internal sealed class CacheHarness : IDisposable +{ + private static readonly Argon2Profile CheapProfile = + Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); + + private readonly MasterKey master; + + private CacheHarness(ClientCacheFactory factory, MasterKey master, LocalCacheProtector protector) + { + Factory = factory; + this.master = master; + Protector = protector; + + Items = new ItemStore(factory, protector); + Outbox = new OutboxStore(factory, protector, TimeProvider.System); + Vaults = new VaultStore(factory, TimeProvider.System); + Unlock = new UnlockStore(factory, TimeProvider.System); + SyncState = new SyncStateStore(factory); + Conflicts = new ConflictStore(factory, protector, TimeProvider.System); + } + + internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0f"); + + internal static Guid UserId { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0f"); + + internal ClientCacheFactory Factory { get; } + + internal LocalCacheProtector Protector { get; } + + internal ItemStore Items { get; } + + internal OutboxStore Outbox { get; } + + internal VaultStore Vaults { get; } + + internal UnlockStore Unlock { get; } + + internal SyncStateStore SyncState { get; } + + internal ConflictStore Conflicts { get; } + + internal static async Task CreateAsync( + string passphrase = "correct horse battery staple") + { + var factory = ClientCacheFactory.ForMemory($"cache-{Guid.CreateVersion7():N}"); + + try + { + await factory.MigrateAsync(TestContext.Current.CancellationToken); + + var salt = new byte[CryptoSpec.SaltSize]; + var derived = MasterKey.Derive(passphrase, salt, CheapProfile); + + return new CacheHarness(factory, derived, LocalCacheProtector.From(derived)); + } + catch + { + factory.Dispose(); + throw; + } + } + + /// + public void Dispose() + { + Protector.Dispose(); + master.Dispose(); + Factory.Dispose(); + } + + // ---- Builders ---- + + internal static EncryptedPayload Payload(byte seed = 1, uint keyGeneration = 1) => + new( + Envelope: [seed, (byte)(seed + 1), (byte)(seed + 2)], + WrappedDataKey: [(byte)(seed + 10), (byte)(seed + 11)], + DataKeyId: Guid.Parse($"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e{seed:x2}"), + KeyGeneration: keyGeneration, + AadVersion: CryptoSpec.CurrentAadVersion); + + internal static StoredItem Item( + Guid entityId, + int version = 1, + long changeSequence = 1, + byte seed = 1, + bool deleted = false, + SyncPlaintextFields? fields = null) => + new( + VaultId, + SyncEntityType.Host, + entityId, + version, + changeSequence, + deleted ? null : Payload(seed), + deleted ? null : fields ?? new SyncPlaintextFields(), + deleted, + DateTimeOffset.FromUnixTimeSeconds(1_750_000_000 + changeSequence)); + + internal static QueuedChange Change( + Guid entityId, + SyncOperation operation = SyncOperation.Upsert, + int? expectedVersion = null, + byte seed = 1, + StoredAncestor? ancestor = null, + SyncPlaintextFields? fields = null) => + new( + VaultId, + SyncEntityType.Host, + entityId, + operation, + expectedVersion, + operation == SyncOperation.Delete ? null : Payload(seed), + operation == SyncOperation.Delete ? null : fields ?? new SyncPlaintextFields(), + ancestor); +} diff --git a/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs b/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs new file mode 100644 index 0000000..151cf27 --- /dev/null +++ b/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs @@ -0,0 +1,401 @@ +using DodoSSH.Contracts; +using DodoSSH.Crypto; +using Microsoft.EntityFrameworkCore; +using static DodoSSH.Client.Storage.Tests.CacheHarness; + +namespace DodoSSH.Client.Storage.Tests; + +/// +/// The item mirror, the vault list, the sync cursor, and the conflict log. +/// +public sealed class CacheStoreTests : IAsyncLifetime +{ + private CacheHarness harness = null!; + + /// + public async ValueTask InitializeAsync() => harness = await CreateAsync(); + + /// + public ValueTask DisposeAsync() + { + harness.Dispose(); + return ValueTask.CompletedTask; + } + + // ---- Items ---- + + [Fact] + public async Task AnItem_RoundTripsItsCiphertextByteForByte() + { + // Not "equivalent" — identical. The AAD binds the row, so re-encrypting locally would work but + // would throw away the ability to notice the server handing back bytes it should not have. + var entityId = Guid.CreateVersion7(); + var item = Item(entityId, version: 4, changeSequence: 17, seed: 3); + + await harness.Items.SaveAsync(item, Token); + + var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token); + + read.ShouldNotBeNull(); + read.Version.ShouldBe(4); + read.ChangeSequence.ShouldBe(17); + read.Payload.ShouldNotBeNull(); + read.Payload.Envelope.ShouldBe(item.Payload!.Envelope); + read.Payload.WrappedDataKey.ShouldBe(item.Payload.WrappedDataKey); + read.Payload.DataKeyId.ShouldBe(item.Payload.DataKeyId); + read.Payload.KeyGeneration.ShouldBe(item.Payload.KeyGeneration); + read.Payload.AadVersion.ShouldBe(item.Payload.AadVersion); + } + + [Fact] + public async Task ThePlaintextFields_RoundTripAndAreNotReadableInTheDatabase() + { + // The server has to hold a relay-enabled host's address in the clear because it resolves it. + // This machine already holds the key that opens the payload, so leaving the address readable in + // a file that ends up in a backup buys nothing. + var entityId = Guid.CreateVersion7(); + var fields = new SyncPlaintextFields( + RelayEnabled: true, Hostname: "bastion.internal", Port: 2222); + + await harness.Items.SaveAsync(Item(entityId, fields: fields), Token); + + var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token); + read!.Fields.ShouldBe(fields); + + var stored = await ReadRawFieldsAsync(entityId); + stored.ShouldNotBeNull(); + + System.Text.Encoding.UTF8.GetString(stored) + .Contains("bastion.internal", StringComparison.Ordinal) + .ShouldBeFalse("the hostname is stored in the clear"); + } + + [Fact] + public async Task ACacheRecord_CannotBeMovedToAnotherRow() + { + // Why the record is bound to its own row rather than only to the user. Swapping two rows would + // otherwise point one host's connection at another host's address. + var mine = Guid.CreateVersion7(); + var other = Guid.CreateVersion7(); + + var sealedFields = harness.Protector.Protect( + CryptoSpec.AadResourceType.Host, + mine, + PlaintextFieldsCodec.Encode(new SyncPlaintextFields(true, "mine.internal", 22))); + + harness.Protector + .TryUnprotect(CryptoSpec.AadResourceType.Host, other, sealedFields) + .ShouldBeNull(); + + harness.Protector + .TryUnprotect(CryptoSpec.AadResourceType.Credential, mine, sealedFields) + .ShouldBeNull(); + + harness.Protector + .TryUnprotect(CryptoSpec.AadResourceType.Host, mine, sealedFields) + .ShouldNotBeNull(); + } + + [Fact] + public async Task ARecordSealedUnderAnotherPassphrase_DoesNotOpen() + { + var entityId = Guid.CreateVersion7(); + + using var stranger = await CreateAsync(passphrase: "a completely different passphrase"); + + var sealedFields = stranger.Protector.Protect( + CryptoSpec.AadResourceType.Host, entityId, [1, 2, 3]); + + harness.Protector + .TryUnprotect(CryptoSpec.AadResourceType.Host, entityId, sealedFields) + .ShouldBeNull(); + } + + [Fact] + public async Task SavingTwice_ReplacesRatherThanDuplicates() + { + var entityId = Guid.CreateVersion7(); + + await harness.Items.SaveAsync(Item(entityId, version: 1, seed: 1), Token); + await harness.Items.SaveAsync(Item(entityId, version: 2, seed: 9), Token); + + var items = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token); + + items.ShouldHaveSingleItem().Version.ShouldBe(2); + } + + [Fact] + public async Task ATombstone_IsHiddenFromTheListButStillFindable() + { + // The interface must not show a deleted host. The sync engine must still be able to tell a + // deleted item from one it has never seen — a row that simply vanished is indistinguishable + // from the latter, and would silently reappear. + var entityId = Guid.CreateVersion7(); + + await harness.Items.SaveAsync(Item(entityId, version: 1), Token); + await harness.Items.SaveAsync(Item(entityId, version: 2, deleted: true), Token); + + (await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token)).ShouldBeEmpty(); + + var withDeleted = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token); + withDeleted.ShouldHaveSingleItem().IsDeleted.ShouldBeTrue(); + + var found = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token); + found.ShouldNotBeNull(); + found.IsDeleted.ShouldBeTrue(); + found.Payload.ShouldBeNull(); + } + + [Fact] + public async Task CollectingTombstones_LeavesLiveItemsAlone() + { + var live = Guid.CreateVersion7(); + var dead = Guid.CreateVersion7(); + + await harness.Items.SaveAsync(Item(live, changeSequence: 1), Token); + await harness.Items.SaveAsync(Item(dead, changeSequence: 2, deleted: true), Token); + + var cutoff = DateTimeOffset.FromUnixTimeSeconds(1_750_000_100); + var collected = await harness.Items.CollectTombstonesAsync(VaultId, cutoff, Token); + + collected.ShouldBe(1); + + var remaining = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token); + remaining.ShouldHaveSingleItem().EntityId.ShouldBe(live); + } + + // ---- Vaults ---- + + [Fact] + public async Task ReplacingTheVaultList_AddsUpdatesAndRemoves() + { + var keep = Guid.CreateVersion7(); + var drop = Guid.CreateVersion7(); + + await harness.Vaults.ReplaceAllAsync( + [Vault(keep, "Personal", 1), Vault(drop, "Old team", 1)], Token); + + await harness.Vaults.ReplaceAllAsync([Vault(keep, "Renamed", 2)], Token); + + var vaults = await harness.Vaults.ListAsync(Token); + var only = vaults.ShouldHaveSingleItem(); + + only.VaultId.ShouldBe(keep); + only.Name.ShouldBe("Renamed"); + only.KeyGeneration.ShouldBe(2u); + (await harness.Vaults.FindAsync(drop, Token)).ShouldBeNull(); + } + + [Fact] + public async Task AVaultsWrappedKey_IsCachedSoAnOfflineLaunchCanDecrypt() + { + // Without this, an offline start could unlock the identity bundle and still not open a single + // item. + var vaultId = Guid.CreateVersion7(); + byte[] wrapped = [9, 9, 9, 9]; + + await harness.Vaults.ReplaceAllAsync( + [Vault(vaultId, "Personal", 1) with { WrappedVaultKey = wrapped }], Token); + + var read = await harness.Vaults.FindAsync(vaultId, Token); + read!.WrappedVaultKey.ShouldBe(wrapped); + } + + // ---- Unlock material ---- + + [Fact] + public async Task TheUnlockMaterial_SurvivesTheContextThatWroteIt() + { + // The offline unlock story, asserted rather than assumed: a fresh store over the same database + // reads back the salt and the wrapped bundle with no network involved. + var material = Material(); + + await harness.Unlock.SaveAsync(material, Token); + + var reader = new UnlockStore(harness.Factory, TimeProvider.System); + var read = await reader.ReadAsync(Token); + + read.ShouldNotBeNull(); + read.UserId.ShouldBe(material.UserId); + read.WrappedPrivateKey.ShouldBe(material.WrappedPrivateKey); + read.KdfParameters.Salt.ShouldBe(material.KdfParameters.Salt); + read.KdfParameters.MemoryKibibytes.ShouldBe(material.KdfParameters.MemoryKibibytes); + read.KdfParameters.Passes.ShouldBe(material.KdfParameters.Passes); + } + + [Fact] + public async Task SavingTheUnlockMaterialTwice_UpdatesTheSingleRow() + { + await harness.Unlock.SaveAsync(Material(), Token); + await harness.Unlock.SaveAsync(Material() with { Email = "changed@example.com" }, Token); + + var read = await harness.Unlock.ReadAsync(Token); + read!.Email.ShouldBe("changed@example.com"); + + (await CountUnlockRowsAsync()).ShouldBe(1); + } + + [Fact] + public async Task AnotherUsersMaterial_IsRefusedRatherThanMixedIn() + { + // Adopting it would offer an unlock prompt whose passphrase can never work, and would mix one + // user's items into another's vault list. + await harness.Unlock.SaveAsync(Material(), Token); + + var other = Material() with { UserId = Guid.CreateVersion7() }; + + await Should.ThrowAsync( + async () => await harness.Unlock.SaveAsync(other, Token)); + } + + // ---- Sync state ---- + + [Fact] + public async Task AnUnknownVault_ReadsAsStartingFromTheBeginning() + { + // Not an error. A null cursor is exactly right for a vault this client has not synced, and is + // also the recovery path for a cache that had to be discarded. + var state = await harness.SyncState.ReadAsync(Guid.CreateVersion7(), Token); + + state.Cursor.ShouldBeNull(); + state.KeyGeneration.ShouldBe(0u); + } + + [Fact] + public async Task TheCursor_IsStoredVerbatim() + { + // Opaque and integrity-tagged. A client that adjusted one could ask to resume from a position + // the server never granted; storing it untouched is the only correct handling. + const string Cursor = "v1.aGVsbG8gd29ybGQ.c2lnbmF0dXJl"; + + await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, Cursor, 3), Token); + + var read = await harness.SyncState.ReadAsync(VaultId, Token); + read.Cursor.ShouldBe(Cursor); + read.KeyGeneration.ShouldBe(3u); + } + + [Fact] + public async Task ResettingAVault_DropsItsItemsButKeepsTheOutbox() + { + // Local unpushed changes are the only copy of the user's work. Clearing them along with the + // cache would turn a recoverable cache problem into lost work. + var entityId = Guid.CreateVersion7(); + + await harness.Items.SaveAsync(Item(entityId), Token); + await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token); + await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, "cursor", 1), Token); + + await harness.SyncState.ResetAsync(VaultId, Token); + + (await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).ShouldBeEmpty(); + (await harness.SyncState.ReadAsync(VaultId, Token)).Cursor.ShouldBeNull(); + (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem(); + } + + // ---- Conflicts ---- + + [Fact] + public async Task AConflictDetail_RoundTripsAndIsSealedAtRest() + { + // This is the one place the cache holds decrypted vault content on purpose: the value a merge + // displaced. It has to be readable to be useful, and it is as sensitive as the item it came + // from. + var entityId = Guid.CreateVersion7(); + var detail = System.Text.Encoding.UTF8.GetBytes("""{"field":"Notes","discarded":"my secret"}"""); + + var id = await harness.Conflicts.RecordAsync( + VaultId, SyncEntityType.Host, entityId, ConflictKind.FieldOverridden, detail, Token); + + var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem(); + listed.Id.ShouldBe(id); + listed.Kind.ShouldBe(ConflictKind.FieldOverridden); + listed.Detail.ShouldBe(detail); + + var raw = await ReadRawConflictDetailAsync(id); + System.Text.Encoding.UTF8.GetString(raw!) + .Contains("my secret", StringComparison.Ordinal) + .ShouldBeFalse("the discarded value is stored in the clear"); + } + + [Fact] + public async Task AnAcknowledgedConflict_LeavesTheListButKeepsTheValue() + { + // Someone who dismisses a warning and realises a minute later that they wanted the other value + // should still be able to get it. + var id = await harness.Conflicts.RecordAsync( + VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.FieldOverridden, + new byte[] { 1, 2, 3 }, Token); + + (await harness.Conflicts.AcknowledgeAsync(id, Token)).ShouldBeTrue(); + + (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldBeEmpty(); + + var all = (await harness.Conflicts.ListAsync(VaultId, true, Token)).ShouldHaveSingleItem(); + all.Detail.ShouldBe(new byte[] { 1, 2, 3 }); + } + + [Fact] + public async Task AnUnacknowledgedConflict_CannotBeDiscarded() + { + var id = await harness.Conflicts.RecordAsync( + VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.Undecryptable, + new byte[] { 1 }, Token); + + (await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeFalse(); + + await harness.Conflicts.AcknowledgeAsync(id, Token); + (await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeTrue(); + } + + // ---- Helpers ---- + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static StoredVault Vault(Guid vaultId, string name, uint keyGeneration) => + new(vaultId, name, IsPersonal: true, TeamId: null, keyGeneration, Permissions: 31, + WrappedVaultKey: [1, 2, 3], RekeyRequired: false); + + private static StoredUnlockMaterial Material() => + new( + "https://dodossh.example", + UserId, + "https://idp.example", + "alice", + "alice@example.com", + "Alice", + KeyGeneration: 1, + WrappedPrivateKey: [4, 5, 6, 7], + new KdfParameters("argon2id", [8, 9, 10, 11], 262144, 4, 1), + DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)); + + private async Task ReadRawFieldsAsync(Guid entityId) + { + var context = harness.Factory.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + return await context.Set() + .Where(row => row.EntityId == entityId) + .Select(row => row.ProtectedFields) + .SingleAsync(Token); + } + + private async Task ReadRawConflictDetailAsync(Guid id) + { + var context = harness.Factory.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + return await context.Set() + .Where(row => row.Id == id) + .Select(row => row.Detail) + .SingleAsync(Token); + } + + private async Task CountUnlockRowsAsync() + { + var context = harness.Factory.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + return await context.Set().CountAsync(Token); + } +} diff --git a/tests/DodoSSH.Client.Storage.Tests/DodoSSH.Client.Storage.Tests.csproj b/tests/DodoSSH.Client.Storage.Tests/DodoSSH.Client.Storage.Tests.csproj new file mode 100644 index 0000000..e443473 --- /dev/null +++ b/tests/DodoSSH.Client.Storage.Tests/DodoSSH.Client.Storage.Tests.csproj @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/tests/DodoSSH.Client.Storage.Tests/OutboxStoreTests.cs b/tests/DodoSSH.Client.Storage.Tests/OutboxStoreTests.cs new file mode 100644 index 0000000..10c28b9 --- /dev/null +++ b/tests/DodoSSH.Client.Storage.Tests/OutboxStoreTests.cs @@ -0,0 +1,286 @@ +using DodoSSH.Contracts; +using static DodoSSH.Client.Storage.Tests.CacheHarness; + +namespace DodoSSH.Client.Storage.Tests; + +/// +/// The outbox, whose coalescing rules are where offline work is kept or lost. +/// +/// +/// Two properties carry the weight. The ancestor must survive every coalesce, or a conflict can only +/// be arbitrated rather than merged. And a coalesced row must get a fresh operation id, or the server +/// can answer Duplicate for an operation whose contents have since changed and silently discard +/// the newer edit. +/// +public sealed class OutboxStoreTests : IAsyncLifetime +{ + private CacheHarness harness = null!; + + /// + public async ValueTask InitializeAsync() => harness = await CreateAsync(); + + /// + public ValueTask DisposeAsync() + { + harness.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task AQueuedChange_ComesBackWithEverythingItNeedsToBePushed() + { + var entityId = Guid.CreateVersion7(); + var ancestor = new StoredAncestor(3, Payload(seed: 40), new SyncPlaintextFields()); + + var queued = await harness.Outbox.QueueAsync( + Change(entityId, expectedVersion: 3, seed: 7, ancestor: ancestor), Token); + + queued.OperationId.ShouldNotBe(Guid.Empty); + queued.ExpectedVersion.ShouldBe(3); + queued.Operation.ShouldBe(SyncOperation.Upsert); + queued.Payload.ShouldNotBeNull(); + queued.Payload.Envelope.ShouldBe(Payload(seed: 7).Envelope); + queued.Payload.WrappedDataKey.ShouldBe(Payload(seed: 7).WrappedDataKey); + queued.Payload.DataKeyId.ShouldBe(Payload(seed: 7).DataKeyId); + queued.Ancestor.ShouldNotBeNull(); + queued.Ancestor.Version.ShouldBe(3); + queued.Ancestor.Payload.Envelope.ShouldBe(Payload(seed: 40).Envelope); + } + + [Fact] + public async Task ASecondEditToTheSameItem_CoalescesIntoOneRow() + { + // Two rows would have to be pushed in order, and the second's expectedVersion is the version + // the first will produce — which is not known when it is queued. + var entityId = Guid.CreateVersion7(); + + await harness.Outbox.QueueAsync(Change(entityId, seed: 1), Token); + await harness.Outbox.QueueAsync(Change(entityId, seed: 2), Token); + + var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token); + + pending.ShouldHaveSingleItem().Payload!.Envelope.ShouldBe(Payload(seed: 2).Envelope); + } + + [Fact] + public async Task ACoalescedEdit_KeepsTheOriginalAncestorAndExpectedVersion() + { + // The load-bearing rule. The newest state is still a descendant of the base the first edit + // branched from; adopting the caller's values here would discard the common ancestor after the + // first edit and leave nothing to merge against. + var entityId = Guid.CreateVersion7(); + var ancestor = new StoredAncestor(5, Payload(seed: 90), new SyncPlaintextFields()); + + await harness.Outbox.QueueAsync( + Change(entityId, expectedVersion: 5, seed: 1, ancestor: ancestor), Token); + + // A second edit arrives knowing nothing about the base. + await harness.Outbox.QueueAsync( + Change(entityId, expectedVersion: null, seed: 2, ancestor: null), Token); + + var pending = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem(); + + pending.ExpectedVersion.ShouldBe(5); + pending.Ancestor.ShouldNotBeNull(); + pending.Ancestor.Version.ShouldBe(5); + pending.Ancestor.Payload.Envelope.ShouldBe(Payload(seed: 90).Envelope); + } + + [Fact] + public async Task ACoalescedEdit_GetsAFreshOperationId() + { + // Reusing the id would let the server report Duplicate — meaning "already applied" — for an + // operation whose payload has since changed, and the newer edit would vanish with the push + // reported as a success. + var entityId = Guid.CreateVersion7(); + + var first = await harness.Outbox.QueueAsync(Change(entityId, seed: 1), Token); + await harness.Outbox.MarkDispatchedAsync(first.Sequence, Token); + + var second = await harness.Outbox.QueueAsync(Change(entityId, seed: 2), Token); + + second.OperationId.ShouldNotBe(first.OperationId); + second.Sequence.ShouldBe(first.Sequence); + + // And the retry counter resets, because this is a new operation rather than a further attempt + // at the old one. + second.Attempts.ShouldBe(0); + } + + [Fact] + public async Task AnUpsertFollowedByADelete_BecomesADelete() + { + var entityId = Guid.CreateVersion7(); + + await harness.Outbox.QueueAsync(Change(entityId, expectedVersion: 2, seed: 1), Token); + await harness.Outbox.QueueAsync( + Change(entityId, SyncOperation.Delete, expectedVersion: 2), Token); + + var pending = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem(); + + pending.Operation.ShouldBe(SyncOperation.Delete); + pending.Payload.ShouldBeNull(); + } + + [Fact] + public async Task ChangesToDifferentItems_DrainInTheOrderTheyWereMade() + { + // Order matters for creates that reference each other — a host naming a jump host — so the + // outbox is a queue, not a set. + var first = Guid.CreateVersion7(); + var second = Guid.CreateVersion7(); + var third = Guid.CreateVersion7(); + + foreach (var id in new[] { first, second, third }) + { + await harness.Outbox.QueueAsync(Change(id), Token); + } + + var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token); + + pending.Select(p => p.EntityId).ShouldBe([first, second, third]); + } + + [Fact] + public async Task CoalescingDoesNotJumpTheQueue() + { + // The row keeps its original position. Re-editing the first item should not push it behind + // items queued after it, because the later ones may depend on it existing. + var first = Guid.CreateVersion7(); + var second = Guid.CreateVersion7(); + + await harness.Outbox.QueueAsync(Change(first), Token); + await harness.Outbox.QueueAsync(Change(second), Token); + await harness.Outbox.QueueAsync(Change(first, seed: 9), Token); + + var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token); + + pending.Select(p => p.EntityId).ShouldBe([first, second]); + } + + [Fact] + public async Task Revise_MovesTheAncestorForwardUnlikeQueue() + { + // The opposite intent from a coalesce: a merge has just been performed against a newer server + // version, so that version becomes the base. Leaving the old ancestor would make the re-push + // conflict against the same point for ever. + var entityId = Guid.CreateVersion7(); + var original = new StoredAncestor(1, Payload(seed: 10), new SyncPlaintextFields()); + + var queued = await harness.Outbox.QueueAsync( + Change(entityId, expectedVersion: 1, ancestor: original), Token); + + var merged = new StoredAncestor(4, Payload(seed: 20), new SyncPlaintextFields()); + + var revised = await harness.Outbox.ReviseAsync( + queued.Sequence, + SyncOperation.Upsert, + expectedVersion: 4, + Payload(seed: 30), + new SyncPlaintextFields(), + merged, + Token); + + revised.ShouldNotBeNull(); + revised.ExpectedVersion.ShouldBe(4); + revised.Ancestor!.Version.ShouldBe(4); + revised.Ancestor.Payload.Envelope.ShouldBe(Payload(seed: 20).Envelope); + revised.OperationId.ShouldNotBe(queued.OperationId); + } + + [Fact] + public async Task AParkedOperation_IsNotHandedOutForPushing() + { + // An operation the server called Invalid will never succeed. Retrying it would spin and, worse, + // would block every change queued behind it in a vault the user can still write to. + var parked = Guid.CreateVersion7(); + var healthy = Guid.CreateVersion7(); + + var queued = await harness.Outbox.QueueAsync(Change(parked), Token); + await harness.Outbox.QueueAsync(Change(healthy), Token); + + await harness.Outbox.ParkAsync(queued.Sequence, "Entity type not supported.", Token); + + var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token); + pending.ShouldHaveSingleItem().EntityId.ShouldBe(healthy); + + var listed = (await harness.Outbox.ListParkedAsync(VaultId, Token)).ShouldHaveSingleItem(); + listed.EntityId.ShouldBe(parked); + listed.LastError.ShouldBe("Entity type not supported."); + } + + [Fact] + public async Task ReEditingAParkedOperation_Unparks() + { + // The user's remedy for a rejected change is to change it. That has to actually re-arm it. + var entityId = Guid.CreateVersion7(); + + var queued = await harness.Outbox.QueueAsync(Change(entityId), Token); + await harness.Outbox.ParkAsync(queued.Sequence, "nope", Token); + + var requeued = await harness.Outbox.QueueAsync(Change(entityId, seed: 5), Token); + + requeued.IsParked.ShouldBeFalse(); + requeued.LastError.ShouldBeNull(); + (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem(); + } + + [Fact] + public async Task Complete_RemovesTheOperation() + { + var queued = await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token); + + (await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeTrue(); + (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldBeEmpty(); + + // Idempotent: a drain that retries after a crash must not fail on an already-cleared row. + (await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeFalse(); + } + + [Fact] + public async Task MarkDispatched_CountsAttempts() + { + var queued = await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token); + + await harness.Outbox.MarkDispatchedAsync(queued.Sequence, Token); + await harness.Outbox.MarkDispatchedAsync(queued.Sequence, Token); + + var pending = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem(); + pending.Attempts.ShouldBe(2); + } + + [Fact] + public async Task AnUpsertWithoutAPayload_IsRefused() + { + // Caught here rather than at the server, where it would come back as one opaque Invalid among + // a batch of otherwise good operations. + var change = new QueuedChange( + VaultId, + SyncEntityType.Host, + Guid.CreateVersion7(), + SyncOperation.Upsert, + ExpectedVersion: null, + Payload: null, + Fields: null, + Ancestor: null); + + await Should.ThrowAsync( + async () => await harness.Outbox.QueueAsync(change, Token)); + } + + [Fact] + public async Task ThePendingOperationForAnItem_CanBeLookedUpDirectly() + { + // How a pull discovers that an incoming change collides with local work. + var entityId = Guid.CreateVersion7(); + await harness.Outbox.QueueAsync(Change(entityId), Token); + + (await harness.Outbox.FindAsync(VaultId, SyncEntityType.Host, entityId, Token)) + .ShouldNotBeNull(); + + (await harness.Outbox.FindAsync(VaultId, SyncEntityType.Host, Guid.CreateVersion7(), Token)) + .ShouldBeNull(); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; +} diff --git a/tests/DodoSSH.Client.Storage.Tests/packages.lock.json b/tests/DodoSSH.Client.Storage.Tests/packages.lock.json new file mode 100644 index 0000000..d0940b0 --- /dev/null +++ b/tests/DodoSSH.Client.Storage.Tests/packages.lock.json @@ -0,0 +1,423 @@ +{ + "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==" + }, + "NSubstitute": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "Shouldly": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==", + "dependencies": { + "DiffEngine": "11.3.0", + "EmptyFiles": "4.4.0" + } + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "DiffEngine": { + "type": "Transitive", + "resolved": "11.3.0", + "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==", + "dependencies": { + "EmptyFiles": "4.4.0", + "System.Management": "6.0.1" + } + }, + "EmptyFiles": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.10", + "Microsoft.EntityFrameworkCore.Relational": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "dodossh.client.storage": { + "type": "Project", + "dependencies": { + "DodoSSH.Contracts": "[1.0.0, )", + "DodoSSH.Crypto": "[1.0.0, )", + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )" + } + }, + "dodossh.contracts": { + "type": "Project" + }, + "dodossh.crypto": { + "type": "Project", + "dependencies": { + "NSec.Cryptography": "[26.4.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "NSec.Cryptography": { + "type": "CentralTransitive", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.12", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.12" + } + }, + "SQLitePCLRaw.core": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.12" + } + } + } + } +} \ No newline at end of file diff --git a/tests/DodoSSH.Client.Sync.Tests/ConflictMatrixTests.cs b/tests/DodoSSH.Client.Sync.Tests/ConflictMatrixTests.cs new file mode 100644 index 0000000..6807ea7 --- /dev/null +++ b/tests/DodoSSH.Client.Sync.Tests/ConflictMatrixTests.cs @@ -0,0 +1,524 @@ +using DodoSSH.Client.Storage; +using static DodoSSH.Client.Sync.Tests.SyncHarness; + +namespace DodoSSH.Client.Sync.Tests; + +/// +/// Two machines, one vault, every way they can disagree. +/// +/// +/// The highest-value suite in the product, because this is the only place data can be lost. Every case +/// asserts two things: that the two devices converge on the same state, and that whatever the merge had +/// to override is recorded rather than gone. A merge that quietly drops the password someone just typed +/// is worse than one that refuses to merge at all. +/// +public sealed class ConflictMatrixTests : IAsyncLifetime +{ + private SyncHarness harness = null!; + + /// + public async ValueTask InitializeAsync() => harness = await CreateAsync(); + + /// + public ValueTask DisposeAsync() + { + harness.Dispose(); + return ValueTask.CompletedTask; + } + + // ---- The uncontested paths ---- + + [Fact] + public async Task ACreatedHost_ReachesTheOtherMachine() + { + var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "primary")); + + await harness.SettleAsync(); + + var seen = await harness.Second.FindAsync(entityId); + seen.Host.Label.ShouldBe("prod-db"); + seen.Host.Notes.ShouldBe("primary"); + seen.HasUnsyncedChanges.ShouldBeFalse(); + harness.Server.RowCount.ShouldBe(1); + } + + [Fact] + public async Task AHostCreatedOffline_IsVisibleLocallyBeforeAnySync() + { + // The reason the outbox exists. A host typed in on a plane has to be usable on that plane. + var entityId = await harness.First.CreateAsync(Host("prod-db")); + + var local = await harness.First.FindAsync(entityId); + local.Host.Label.ShouldBe("prod-db"); + local.HasUnsyncedChanges.ShouldBeTrue(); + + harness.Server.RowCount.ShouldBe(0); + } + + [Fact] + public async Task ALocalOnlyEdit_IsPushed() + { + var entityId = await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "rotate quarterly")); + await harness.SettleAsync(); + + (await harness.Second.FindAsync(entityId)).Host.Notes.ShouldBe("rotate quarterly"); + (await harness.First.ConflictsAsync()).ShouldBeEmpty(); + } + + [Fact] + public async Task ARemoteOnlyEdit_IsPulledWithoutAConflict() + { + var entityId = await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + await harness.Second.UpdateAsync(entityId, Host("prod-db", username: "postgres")); + await harness.SettleAsync(); + + (await harness.First.FindAsync(entityId)).Host.Username.ShouldBe("postgres"); + (await harness.First.ConflictsAsync()).ShouldBeEmpty(); + } + + // ---- Both edited ---- + + [Fact] + public async Task BothEditedDifferentFields_BothSurvive() + { + // The payoff for a field-level merge. Last-writer-wins would lose one of these. + var entityId = await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "from the laptop")); + await harness.Second.UpdateAsync(entityId, Host("prod-db", username: "postgres")); + + await harness.SettleAsync(); + + var merged = (await harness.First.FindAsync(entityId)).Host; + merged.Notes.ShouldBe("from the laptop"); + merged.Username.ShouldBe("postgres"); + + (await harness.Second.FindAsync(entityId)).Host.ShouldBe(merged); + (await harness.First.ConflictsAsync()).ShouldBeEmpty(); + } + + [Fact] + public async Task BothAddedADifferentDirective_BothSurvive() + { + var entityId = await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + await harness.First.UpdateAsync( + entityId, Host("prod-db", options: [("Compression", "yes")])); + await harness.Second.UpdateAsync( + entityId, Host("prod-db", options: [("ServerAliveInterval", "30")])); + + await harness.SettleAsync(); + + var merged = (await harness.First.FindAsync(entityId)).Host; + merged.Options.Count.ShouldBe(2); + merged.Options.TryGetValue("Compression", out _).ShouldBeTrue(); + merged.Options.TryGetValue("ServerAliveInterval", out _).ShouldBeTrue(); + } + + [Fact] + public async Task BothEditedTheSameField_OneValueWinsAndTheOtherIsRecorded() + { + // A genuine clash. Whichever side loses, its value has to be retrievable — that is the entire + // justification for resolving automatically instead of blocking. + var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "original")); + await harness.SettleAsync(); + + await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "from the laptop")); + await harness.Second.UpdateAsync(entityId, Host("prod-db", notes: "from the desktop")); + + await harness.SettleAsync(); + + var first = (await harness.First.FindAsync(entityId)).Host; + var second = (await harness.Second.FindAsync(entityId)).Host; + + first.ShouldBe(second); + + var winner = first.Notes.ShouldNotBeNull(); + var lost = string.Equals(winner, "from the laptop", StringComparison.Ordinal) + ? "from the desktop" + : "from the laptop"; + + // One of the two, and the same one on both machines. Which is not the point; that the other is + // retrievable is. + new[] { "from the laptop", "from the desktop" } + .Contains(winner, StringComparer.Ordinal) + .ShouldBeTrue(); + + (await ConflictsAcrossDevicesAsync()) + .ShouldContain(kind => kind == ConflictKind.FieldOverridden); + + (await DiscardedValuesAsync()) + .ShouldContain( + detail => detail.Contains(lost, StringComparison.Ordinal), + "the overridden value must be recoverable from the conflict log"); + } + + [Fact] + public async Task BothMadeTheSameEdit_IsNotAConflict() + { + // Two people fixing the same typo must not be asked to arbitrate. + var entityId = await harness.First.CreateAsync(Host("prod-db", hostname: "db.internl")); + await harness.SettleAsync(); + + await harness.First.UpdateAsync(entityId, Host("prod-db", hostname: "db.internal")); + await harness.Second.UpdateAsync(entityId, Host("prod-db", hostname: "db.internal")); + + await harness.SettleAsync(); + + (await harness.First.FindAsync(entityId)).Host.Hostname.ShouldBe("db.internal"); + (await ConflictsAcrossDevicesAsync()).ShouldBeEmpty(); + } + + // ---- Deletes ---- + + [Fact] + public async Task ADeletedHost_DisappearsEverywhere() + { + var entityId = await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + await harness.First.DeleteAsync(entityId); + await harness.SettleAsync(); + + (await harness.First.ListAsync()).Hosts.ShouldBeEmpty(); + (await harness.Second.ListAsync()).Hosts.ShouldBeEmpty(); + harness.Server.RowCount.ShouldBe(0); + } + + [Fact] + public async Task BothDeleted_IsNotAConflict() + { + var entityId = await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + await harness.First.DeleteAsync(entityId); + await harness.Second.DeleteAsync(entityId); + + await harness.SettleAsync(); + + (await harness.First.ListAsync()).Hosts.ShouldBeEmpty(); + (await harness.Second.ListAsync()).Hosts.ShouldBeEmpty(); + (await ConflictsAcrossDevicesAsync()).ShouldBeEmpty(); + } + + [Fact] + public async Task DeletedElsewhereWhileEditedHere_TheLocalWorkSurvivesUnderANewName() + { + // The case where naive handling loses data outright. The tombstone has to stand — arguing with it + // conflicts for ever — so the edit is preserved as a separate host instead of being dropped. + var entityId = await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + // The laptop syncs first, so its delete is what reaches the server; the desktop's edit is the + // one that has to be rescued. Which side loses is decided by who gets there first, and both + // orderings are covered — see EditedElsewhereWhileDeletedHere for the mirror image. + await harness.First.DeleteAsync(entityId); + await harness.Second.UpdateAsync( + entityId, Host("prod-db", notes: "credentials rotated, do not delete")); + + await harness.SettleAsync(); + + var listing = await harness.First.ListAsync(); + + var restored = listing.Hosts.ShouldHaveSingleItem(); + restored.EntityId.ShouldNotBe(entityId); + restored.Host.Label.ShouldBe("prod-db (restored)"); + restored.Host.Notes.ShouldBe("credentials rotated, do not delete"); + + (await ConflictsAcrossDevicesAsync()) + .ShouldContain(kind => kind == ConflictKind.RemoteDeleteResurrected); + + // And the other machine sees it too, so the rescue is not local-only. + (await harness.Second.ListAsync()).Hosts.ShouldHaveSingleItem() + .EntityId.ShouldBe(restored.EntityId); + } + + [Fact] + public async Task ReplayingAPulledDeletion_DoesNotDuplicateTheRescuedCopy() + { + // Applying a pulled change is at-least-once — the cursor is saved after the page is applied — so + // a whole page can arrive twice. This checks the replay is harmless end to end; the deterministic + // id it relies on is pinned by ResurrectionIdTests. + var entityId = await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + await harness.First.DeleteAsync(entityId); + await harness.Second.UpdateAsync(entityId, Host("prod-db", notes: "keep me")); + await harness.First.SyncAsync(); + + // Rewind the desktop's cursor, so the deletion arrives a second time and it tries to rescue the + // same content twice. + await harness.Second.SyncAsync(); + await harness.Second.SyncState.ResetAsync(VaultId, TestContext.Current.CancellationToken); + await harness.Second.SyncAsync(); + + await harness.SettleAsync(); + + (await harness.First.ListAsync()).Hosts.Count.ShouldBe(1); + harness.Server.RowCount.ShouldBe(1); + } + + [Fact] + public async Task EditedElsewhereWhileDeletedHere_TheDeleteIsAbandonedAndReported() + { + // The mirror image, and resolved the same way round: an edit outlives a removal. Re-deleting + // costs a click; a discarded edit may be the only copy of something. + var entityId = await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "still needed")); + await harness.Second.DeleteAsync(entityId); + + await harness.SettleAsync(); + + var survivor = await harness.First.FindAsync(entityId); + survivor.Host.Notes.ShouldBe("still needed"); + + (await ConflictsAcrossDevicesAsync()) + .ShouldContain(kind => kind == ConflictKind.LocalDeleteOverridden); + } + + // ---- Ordering, retries and idempotence ---- + + [Fact] + public async Task OfflineChanges_ArePushedInTheOrderTheyWereMade() + { + var first = await harness.First.CreateAsync(Host("a-bastion")); + var second = await harness.First.CreateAsync(Host("b-database")); + var third = await harness.First.CreateAsync(Host("c-cache")); + + await harness.SettleAsync(); + + var order = (await harness.Second.ListAsync()).Hosts + .OrderBy(host => host.Version) + .ThenBy(host => host.Host.Label, StringComparer.Ordinal) + .Select(host => host.EntityId) + .ToArray(); + + order.ShouldBe([first, second, third], ignoreOrder: false); + } + + [Fact] + public async Task ASecondSyncPass_ChangesNothing() + { + // Idempotence, which is what makes re-reading a client's own writes a safe way to avoid the + // cursor-gap hazard in the push response. + await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + var before = await harness.First.HostsSortedAsync(); + var pushesBefore = harness.Server.PushCount; + + var report = await harness.First.SyncAsync(); + + report.Pushed.ShouldBe(0); + harness.Server.PushCount.ShouldBe(pushesBefore); + (await harness.First.HostsSortedAsync()).ShouldBe(before); + } + + [Fact] + public async Task AnEditWhileAnEarlierPushIsUnacknowledged_KeepsTheNewerValueWithoutDuplicating() + { + // A create that reached the server and whose answer did not come back, followed by another edit. + // The newer value has to win and there must be exactly one host afterwards. Two mechanisms keep + // that true: the pull sees the server's row and re-bases the queued edit onto it, and a coalesced + // row carries a fresh operation id so the server cannot answer Duplicate — "already applied" — + // for an operation whose contents have since changed. The second is pinned directly by + // OutboxStoreTests.ACoalescedEdit_GetsAFreshOperationId; here they are exercised together. + var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "first")); + + // The create lands on the server, but the acknowledgement never reaches the laptop. + await PushBehindTheEnginesBackAsync(entityId); + + await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "second")); + await harness.SettleAsync(); + + harness.Server.RowCount.ShouldBe(1); + (await harness.First.FindAsync(entityId)).Host.Notes.ShouldBe("second"); + (await harness.Second.FindAsync(entityId)).Host.Notes.ShouldBe("second"); + } + + [Fact] + public async Task AConcurrentWriteDuringAPush_IsNotSkipped() + { + // The cursor-gap hazard. The push response carries a cursor sitting after this client's own + // changes; adopting it would skip anything another client committed at a lower sequence in the + // window between this client's pull and its push. The engine keeps its own cursor instead. + var mine = await harness.First.CreateAsync(Host("mine")); + + var theirs = Guid.CreateVersion7(); + harness.Server.OnPush = () => harness.Server.ExternalUpsert(theirs, ForeignPayload(), null); + + await harness.First.SyncAsync(); + + var ids = (await harness.First.Items + .ListAsync(VaultId, Contracts.SyncEntityType.Host, false, TestContext.Current.CancellationToken)) + .Select(item => item.EntityId) + .ToArray(); + + ids.ShouldContain(mine); + ids.ShouldContain(theirs, "a change committed during the push was skipped"); + } + + [Fact] + public async Task AForbiddenWrite_IsParkedRatherThanRetriedForever() + { + var entityId = await harness.First.CreateAsync(Host("prod-db")); + + harness.Server.DenyWrites = true; + var report = await harness.First.SyncAsync(); + + report.Parked.ShouldBe(1); + (await harness.First.Outbox.ListParkedAsync(VaultId, TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem().EntityId.ShouldBe(entityId); + + // A parked operation is not retried, so a second pass sends nothing. + var pushes = harness.Server.PushCount; + await harness.First.SyncAsync(); + harness.Server.PushCount.ShouldBe(pushes); + + (await harness.First.ConflictsAsync()).ShouldContain(c => c.Kind == ConflictKind.Rejected); + } + + [Fact] + public async Task AParkedChange_IsStillWhatTheUserSees() + { + // Hiding it because the server refused would show the old values and look like the edit was lost. + var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "mine")); + + harness.Server.DenyWrites = true; + await harness.First.SyncAsync(); + + var host = await harness.First.FindAsync(entityId); + host.Host.Notes.ShouldBe("mine"); + host.IsBlocked.ShouldBeTrue(); + host.HasUnsyncedChanges.ShouldBeTrue(); + } + + [Fact] + public async Task ARekeyedVault_IsReportedRatherThanShowingAnEmptyList() + { + // After a rekey this client's grant is stale, so items pulled meanwhile cannot be read. Silently + // showing nothing would look exactly like an empty vault. + await harness.First.CreateAsync(Host("prod-db")); + await harness.SettleAsync(); + + harness.Server.KeyGeneration = 2; + + var report = await harness.First.SyncAsync(); + + report.RekeyRequired.ShouldBeTrue(); + report.ServerKeyGeneration.ShouldBe(2u); + report.NeedsAttention.ShouldBeTrue(); + } + + // ---- The overall property ---- + + [Fact] + public async Task AfterAnInterleavedSession_BothMachinesAgree() + { + // Convergence, over a fixed script that exercises creates, edits, a delete and a resurrection at + // once. Two devices that ended up with different host lists would be the worst possible outcome + // for a synced vault, and no single-case test rules it out. + var shared = await harness.First.CreateAsync(Host("a-shared")); + var doomed = await harness.First.CreateAsync(Host("b-doomed")); + await harness.SettleAsync(); + + await harness.First.UpdateAsync(shared, Host("a-shared", notes: "laptop note")); + await harness.Second.UpdateAsync(shared, Host("a-shared", username: "desktop-user")); + + await harness.First.DeleteAsync(doomed); + await harness.Second.UpdateAsync(doomed, Host("b-doomed", notes: "still wanted")); + + await harness.First.CreateAsync(Host("c-laptop-only")); + await harness.Second.CreateAsync(Host("d-desktop-only")); + + await harness.SettleAsync(); + await harness.SettleAsync(); + + var first = await harness.First.HostsSortedAsync(); + var second = await harness.Second.HostsSortedAsync(); + + first.ShouldBe(second); + first.Count.ShouldBe(4); + + first.Select(host => host.Label).ShouldBe( + ["a-shared", "b-doomed (restored)", "c-laptop-only", "d-desktop-only"]); + + // The merged host kept both sides' contributions. + var merged = first.Single(host => string.Equals(host.Label, "a-shared", StringComparison.Ordinal)); + merged.Notes.ShouldBe("laptop note"); + merged.Username.ShouldBe("desktop-user"); + + // And nothing is still queued anywhere. + (await harness.First.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken)) + .ShouldBeEmpty(); + (await harness.Second.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken)) + .ShouldBeEmpty(); + } + + // ---- Helpers ---- + + private static Contracts.EncryptedPayload ForeignPayload() => + // Deliberately not decryptable: this stands in for another user's item, and the point of the test + // is only that the change is not skipped. Pulling never decrypts, so nothing here needs to open. + new([1, 2, 3], [4, 5], Guid.CreateVersion7(), 1, Crypto.CryptoSpec.CurrentAadVersion); + + /// + /// Sends a device's queued operation straight to the server, leaving the outbox row in place. + /// + /// + /// Reproduces the one situation the engine cannot reach on its own: a push that the server applied + /// and whose answer never came back. That window is where an idempotency key either saves the newer + /// edit or destroys it. + /// + private async Task PushBehindTheEnginesBackAsync(Guid entityId) + { + var pending = await harness.First.Outbox.FindAsync( + VaultId, Contracts.SyncEntityType.Host, entityId, TestContext.Current.CancellationToken); + + pending.ShouldNotBeNull(); + + await harness.Server.SyncPushAsync( + VaultId, + new Contracts.SyncPushRequest( + [ + new Contracts.SyncPushOperation( + pending.OperationId, + pending.EntityType, + pending.EntityId, + pending.Operation, + pending.ExpectedVersion, + pending.Payload, + pending.Fields), + ]), + TestContext.Current.CancellationToken); + } + + private async Task> ConflictsAcrossDevicesAsync() + { + var first = await harness.First.ConflictsAsync(); + var second = await harness.Second.ConflictsAsync(); + + return [.. first.Concat(second).Select(conflict => conflict.Kind)]; + } + + private async Task> DiscardedValuesAsync() + { + var first = await harness.First.ConflictsAsync(); + var second = await harness.Second.ConflictsAsync(); + + return + [ + .. first.Concat(second) + .Select(conflict => System.Text.Encoding.UTF8.GetString(conflict.Detail)), + ]; + } +} diff --git a/tests/DodoSSH.Client.Sync.Tests/DodoSSH.Client.Sync.Tests.csproj b/tests/DodoSSH.Client.Sync.Tests/DodoSSH.Client.Sync.Tests.csproj new file mode 100644 index 0000000..f620c05 --- /dev/null +++ b/tests/DodoSSH.Client.Sync.Tests/DodoSSH.Client.Sync.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/tests/DodoSSH.Client.Sync.Tests/FakeVaultServer.cs b/tests/DodoSSH.Client.Sync.Tests/FakeVaultServer.cs new file mode 100644 index 0000000..2e1bfb6 --- /dev/null +++ b/tests/DodoSSH.Client.Sync.Tests/FakeVaultServer.cs @@ -0,0 +1,377 @@ +using System.Globalization; +using DodoSSH.Client.Api; +using DodoSSH.Contracts; + +namespace DodoSSH.Client.Sync.Tests; + +/// +/// An in-memory vault server with the real sync semantics. +/// +/// +/// +/// A faithful reimplementation of DodoSSH.Api.Features.Sync.SyncService's decision table: the +/// version check, the tombstone-beats-late-upsert rule, idempotent deletes, operation receipts, the +/// change log, and cursors that are opaque to the client. It is not a stub that returns canned answers — +/// if it were, none of the conflict tests would mean anything, because the interesting behaviour is +/// exactly the server's refusal to apply a stale write. +/// +/// +/// The duplication against the real service is deliberate and is the point of the exercise: two +/// independent expressions of the same rules, and SyncEndpointTests checks the other one against +/// real Postgres. A shared implementation would let a misreading of the protocol pass on both sides. +/// +/// +internal sealed class FakeVaultServer : ISyncApi +{ + private readonly Dictionary rows = []; + private readonly List log = []; + private readonly Dictionary receipts = []; + + internal FakeVaultServer(Guid vaultId, uint keyGeneration = 1) + { + VaultId = vaultId; + KeyGeneration = keyGeneration; + } + + internal Guid VaultId { get; } + + internal uint KeyGeneration { get; set; } + + /// The server's clock, so a test can create skew deliberately. + internal DateTimeOffset Now { get; set; } = DateTimeOffset.FromUnixTimeSeconds(1_750_000_000); + + /// Pull pages are capped here, as the real server clamps a client's requested limit. + internal int MaxPullLimit { get; set; } = 500; + + /// Forces the next push to answer . + internal bool DenyWrites { get; set; } + + /// Pushes received, so a test can prove a retry did or did not happen. + internal int PushCount { get; private set; } + + /// + /// Runs just before a push is applied, so a test can land another client's write in the window + /// between one client's pull and its push. That window is the whole subject of the cursor-gap test. + /// + internal Action? OnPush { get; set; } + + internal int RowCount => rows.Count(entry => !entry.Value.IsDeleted); + + /// + public Task SyncPullAsync( + Guid vaultId, + SyncPullRequest request, + CancellationToken cancellationToken) + { + var after = DecodeCursor(request.Cursor); + var limit = Math.Clamp(request.Limit ?? MaxPullLimit, 1, MaxPullLimit); + + var page = log.Where(entry => entry.Sequence > after).Take(limit + 1).ToList(); + + var hasMore = page.Count > limit; + if (hasMore) + { + page.RemoveAt(page.Count - 1); + } + + // When nothing came back the cursor must not move, or a write landing between this read and the + // next would be skipped for ever. + var next = page.Count > 0 ? page[^1].Sequence : after; + + return Task.FromResult(new SyncPullResponse( + [.. page.Select(entry => Hydrate(entry))], + EncodeCursor(next), + hasMore, + Now, + KeyGeneration)); + } + + /// + public Task SyncPushAsync( + Guid vaultId, + SyncPushRequest request, + CancellationToken cancellationToken) + { + PushCount++; + + var interleaved = OnPush; + OnPush = null; + interleaved?.Invoke(); + + var results = new List(request.Operations.Count); + + foreach (var operation in request.Operations) + { + results.Add(Apply(operation)); + } + + return Task.FromResult(new SyncPushResponse(results, EncodeCursor(Head))); + } + + /// Applies a change as if another client had made it. + internal int ExternalUpsert(Guid entityId, EncryptedPayload payload, SyncPlaintextFields? fields) + { + var result = Apply(new SyncPushOperation( + Guid.CreateVersion7(), + SyncEntityType.Host, + entityId, + SyncOperation.Upsert, + rows.TryGetValue(entityId, out var existing) && !existing.IsDeleted + ? existing.Version + : null, + payload, + fields ?? new SyncPlaintextFields())); + + if (result.Status != SyncOperationStatus.Applied) + { + throw new InvalidOperationException( + $"The external write was not applied: {result.Status} — {result.Detail}."); + } + + return result.Version!.Value; + } + + /// Deletes as if another client had done it. + internal void ExternalDelete(Guid entityId) + { + var existing = rows[entityId]; + + var result = Apply(new SyncPushOperation( + Guid.CreateVersion7(), + SyncEntityType.Host, + entityId, + SyncOperation.Delete, + existing.Version, + null, + null)); + + if (result.Status != SyncOperationStatus.Applied) + { + throw new InvalidOperationException($"The external delete was not applied: {result.Status}."); + } + } + + internal Row? Find(Guid entityId) => rows.TryGetValue(entityId, out var row) ? row : null; + + private long Head => log.Count == 0 ? 0 : log[^1].Sequence; + + // ---- The decision table ---- + + private SyncPushResult Apply(SyncPushOperation operation) + { + if (operation.EntityType != SyncEntityType.Host) + { + return Invalid(operation, $"Entity type {operation.EntityType} is not yet supported."); + } + + if (receipts.TryGetValue(operation.OperationId, out var receipt)) + { + return new SyncPushResult( + operation.OperationId, + SyncOperationStatus.Duplicate, + receipt.Version, + receipt.Sequence, + null, + null); + } + + if (DenyWrites) + { + return new SyncPushResult( + operation.OperationId, SyncOperationStatus.Forbidden, null, null, null, null); + } + + rows.TryGetValue(operation.EntityId, out var existing); + + return operation.Operation == SyncOperation.Delete + ? ApplyDelete(operation, existing) + : ApplyUpsert(operation, existing); + } + + private SyncPushResult ApplyUpsert(SyncPushOperation operation, Row? existing) + { + if (operation.Payload is null) + { + return Invalid(operation, "An upsert requires a payload."); + } + + if (operation.Payload.WrappedDataKey.Length == 0 || operation.Payload.DataKeyId == Guid.Empty) + { + return Invalid(operation, "A payload requires its data key."); + } + + var fields = operation.PlaintextFields ?? new SyncPlaintextFields(); + + if (!fields.RelayEnabled && (fields.Hostname is not null || fields.Port is not null)) + { + return Invalid(operation, "An address may only be supplied when relay is enabled."); + } + + if (fields.RelayEnabled && (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null)) + { + return Invalid(operation, "Relay-enabled hosts require both a hostname and a port."); + } + + if (existing is null || existing.IsDeleted) + { + return Create(operation, existing, fields); + } + + if (operation.ExpectedVersion != existing.Version) + { + return Conflict(operation, existing); + } + + var updated = existing with + { + Version = existing.Version + 1, + Payload = operation.Payload, + Fields = fields, + IsDeleted = false, + }; + + return Commit(operation, updated, SyncOperation.Upsert); + } + + private SyncPushResult Create(SyncPushOperation operation, Row? existing, SyncPlaintextFields fields) + { + // A tombstone beats a late upsert. The client is told so it can resurrect the item deliberately + // under a new id rather than silently undoing someone else's delete. + if (existing?.IsDeleted == true) + { + return Conflict(operation, existing); + } + + if (operation.ExpectedVersion is not null) + { + // The client believes it is updating something that does not exist here. + return Conflict(operation, existing: null); + } + + var created = new Row(operation.EntityId, 1, 0, operation.Payload!, fields, false); + return Commit(operation, created, SyncOperation.Upsert); + } + + private SyncPushResult ApplyDelete(SyncPushOperation operation, Row? existing) + { + if (existing is null) + { + return Invalid(operation, "Cannot delete an item that does not exist."); + } + + if (existing.IsDeleted) + { + // Idempotent: a client retrying a delete it is unsure about should not have to tell these + // two situations apart. + return new SyncPushResult( + operation.OperationId, + SyncOperationStatus.Applied, + existing.Version, + existing.ChangeSequence, + null, + null); + } + + if (operation.ExpectedVersion is not null && operation.ExpectedVersion != existing.Version) + { + return Conflict(operation, existing); + } + + var tombstone = existing with + { + Version = existing.Version + 1, + IsDeleted = true, + // The address goes with the item, or the server stays able to resolve a host the user + // believes they deleted. + Fields = new SyncPlaintextFields(), + }; + + return Commit(operation, tombstone, SyncOperation.Delete); + } + + private SyncPushResult Commit(SyncPushOperation operation, Row row, SyncOperation change) + { + var sequence = Head + 1; + + log.Add(new LogEntry(sequence, row.EntityId, change, row.Version, Now)); + rows[row.EntityId] = row with { ChangeSequence = sequence }; + receipts[operation.OperationId] = new Receipt(row.Version, sequence); + + return new SyncPushResult( + operation.OperationId, SyncOperationStatus.Applied, row.Version, sequence, null, null); + } + + private SyncPushResult Conflict(SyncPushOperation operation, Row? existing) => + new( + operation.OperationId, + SyncOperationStatus.Conflict, + existing?.Version, + existing?.ChangeSequence, + existing is null ? null : ToChange(existing), + null); + + private static SyncPushResult Invalid(SyncPushOperation operation, string detail) => + new(operation.OperationId, SyncOperationStatus.Invalid, null, null, null, detail); + + private SyncChange Hydrate(LogEntry entry) + { + var row = rows[entry.EntityId]; + return ToChange(row, entry.Sequence, entry.Revision, entry.OccurredAt); + } + + private SyncChange ToChange(Row row, long? sequence = null, int? version = null, DateTimeOffset? at = null) => + new( + SyncEntityType.Host, + row.EntityId, + row.IsDeleted ? SyncOperation.Delete : SyncOperation.Upsert, + version ?? row.Version, + sequence ?? row.ChangeSequence, + // A delete carries no payload: there is nothing left to decrypt, and shipping the pre-delete + // ciphertext would undermine the point of the tombstone. + row.IsDeleted ? null : row.Payload, + row.IsDeleted ? null : row.Fields, + at ?? Now); + + // ---- Cursors ---- + + /// + /// Prefixed and non-numeric so a client that tried to compute one would produce something this + /// rejects. The real server HMAC-tags them; the property that matters to the client is only that it + /// must round-trip what it is given. + /// + private static string EncodeCursor(long sequence) => + "fake-v1:" + sequence.ToString(CultureInfo.InvariantCulture); + + private static long DecodeCursor(string? cursor) + { + if (string.IsNullOrEmpty(cursor)) + { + return 0; + } + + if (!cursor.StartsWith("fake-v1:", StringComparison.Ordinal) + || !long.TryParse(cursor.AsSpan(8), CultureInfo.InvariantCulture, out var sequence)) + { + throw new InvalidOperationException($"A client sent a cursor it should not have: '{cursor}'."); + } + + return sequence; + } + + internal sealed record Row( + Guid EntityId, + int Version, + long ChangeSequence, + EncryptedPayload Payload, + SyncPlaintextFields Fields, + bool IsDeleted); + + private sealed record LogEntry( + long Sequence, + Guid EntityId, + SyncOperation Operation, + int Revision, + DateTimeOffset OccurredAt); + + private sealed record Receipt(int Version, long Sequence); +} diff --git a/tests/DodoSSH.Client.Sync.Tests/HostCipherTests.cs b/tests/DodoSSH.Client.Sync.Tests/HostCipherTests.cs new file mode 100644 index 0000000..fbb4866 --- /dev/null +++ b/tests/DodoSSH.Client.Sync.Tests/HostCipherTests.cs @@ -0,0 +1,204 @@ +using DodoSSH.Client.Domain; +using DodoSSH.Contracts; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Sync.Tests; + +/// +/// Sealing and opening a host payload. +/// +/// +/// Mostly negative tests, and deliberately so. docs/crypto.md §4.4 claims a server holding every +/// ciphertext still cannot move a payload between rows, roll one back to an earlier generation, or pair +/// one item's envelope with another's key wrap. Those claims are only worth making if something checks +/// them at the layer that actually assembles the AAD. +/// +public sealed class HostCipherTests +{ + private static readonly Guid HostA = Guid.Parse("0192f0c8-000a-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly Guid HostB = Guid.Parse("0192f0c8-000b-7c3d-8e4f-5a6b7c8d9e0f"); + + private readonly byte[] vaultKey = VaultKeys.Create(); + private readonly byte[] otherVaultKey = VaultKeys.Create(); + + [Fact] + public void AHost_RoundTrips() + { + var host = Host(); + var payload = HostCipher.Seal(host, vaultKey, HostA, keyGeneration: 1, itemVersion: 1); + + var opened = HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1); + + opened.ShouldNotBeNull(); + opened.Host.ShouldBe(host); + opened.IsReadOnly.ShouldBeFalse(); + } + + [Fact] + public void EverySeal_UsesAFreshDataKey() + { + // One key per item version, so nonce-collision analysis is moot and a rotation re-wraps 32 bytes + // rather than rewriting content. + var host = Host(); + + var first = HostCipher.Seal(host, vaultKey, HostA, 1, 1); + var second = HostCipher.Seal(host, vaultKey, HostA, 1, 1); + + first.DataKeyId.ShouldNotBe(second.DataKeyId); + first.WrappedDataKey.ShouldNotBe(second.WrappedDataKey); + first.Envelope.ShouldNotBe(second.Envelope); + } + + [Fact] + public void APayload_CannotBeReadAsAnotherItem() + { + // The property that stops a server pasting one host's payload onto another row. + var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1); + + HostCipher.TryOpen(payload, vaultKey, HostB, itemVersion: 1).ShouldBeNull(); + } + + [Fact] + public void APayload_CannotBeReadAtAnotherVersion() + { + // The sharpest edge in this layer. A payload is sealed at the version the server will assign, so + // getting that prediction wrong produces something that encrypts cleanly and never decrypts. The + // binding is what turns a silent corruption into a visible failure. + var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 1, itemVersion: 2); + + HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1).ShouldBeNull(); + HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 3).ShouldBeNull(); + HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 2).ShouldNotBeNull(); + } + + [Fact] + public void APayload_CannotBeRolledBackToAnEarlierKeyGeneration() + { + var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 2, itemVersion: 1); + + // The generation travels with the payload, so a server rewriting the column to 1 changes the AAD + // the client recomputes and the tag fails. + var rolledBack = payload with { KeyGeneration = 1 }; + + HostCipher.TryOpen(rolledBack, vaultKey, HostA, itemVersion: 1).ShouldBeNull(); + } + + [Fact] + public void APayload_CannotBeReadWithAnotherVaultsKey() + { + var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1); + + HostCipher.TryOpen(payload, otherVaultKey, HostA, itemVersion: 1).ShouldBeNull(); + } + + [Fact] + public void OneItemsEnvelope_CannotBePairedWithAnothersKeyWrap() + { + // What content_key_id is in the AAD for. Without it the two halves of a payload would be + // interchangeable and a server could mix them. + var first = HostCipher.Seal(Host(label: "one"), vaultKey, HostA, 1, 1); + var second = HostCipher.Seal(Host(label: "two"), vaultKey, HostA, 1, 1); + + var mixed = first with { WrappedDataKey = second.WrappedDataKey }; + + HostCipher.TryOpen(mixed, vaultKey, HostA, itemVersion: 1).ShouldBeNull(); + } + + [Fact] + public void ATamperedEnvelope_DoesNotOpen() + { + var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1); + + var tampered = payload.Envelope.ToArray(); + tampered[^1] ^= 0xFF; + + HostCipher.TryOpen(payload with { Envelope = tampered }, vaultKey, HostA, 1).ShouldBeNull(); + } + + [Fact] + public void ASubstitutedDataKeyId_DoesNotOpen() + { + var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1); + + HostCipher.TryOpen(payload with { DataKeyId = Guid.CreateVersion7() }, vaultKey, HostA, 1) + .ShouldBeNull(); + } + + [Fact] + public void AMissingDataKey_IsRefusedRatherThanThrowing() + { + // What a row written before the data key existed in the contract would look like. It must degrade + // to one unreadable item, not to an exception inside a sync pass. + var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1); + + HostCipher.TryOpen(payload with { WrappedDataKey = [] }, vaultKey, HostA, 1).ShouldBeNull(); + HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 0).ShouldBeNull(); + } + + [Fact] + public void Seal_RefusesAVersionBelowOne() + { + // Versions start at 1, and a zero would silently produce a payload no push could ever match. + Should.Throw( + () => HostCipher.Seal(Host(), vaultKey, HostA, 1, itemVersion: 0)); + } + + [Fact] + public void Seal_RefusesAHostThatCannotBeStored() + { + Should.Throw( + () => HostCipher.Seal(Host(label: " "), vaultKey, HostA, 1, 1)); + } + + [Fact] + public void TheNextVersion_IsOneMoreThanTheVersionBeingReplaced() + { + // The prediction both the sealing and the opening side depend on. If these two ever disagreed the + // result would be an item that encrypts and never decrypts, so they share one definition. + SyncVersions.NextVersion(null).ShouldBe(1); + SyncVersions.NextVersion(1).ShouldBe(2); + SyncVersions.NextVersion(41).ShouldBe(42); + } + + [Fact] + public void ARelayEnabledHost_ExposesItsAddressAndNothingElseDoes() + { + // The single point at which a hostname can leave the payload. With relay off the server learns + // only that an item exists; see ADR 0004. + var off = HostFields.From(Host(relayEnabled: false)); + off.RelayEnabled.ShouldBeFalse(); + off.Hostname.ShouldBeNull(); + off.Port.ShouldBeNull(); + + var on = HostFields.From(Host(hostname: "bastion.internal", port: 2222, relayEnabled: true)); + on.RelayEnabled.ShouldBeTrue(); + on.Hostname.ShouldBe("bastion.internal"); + on.Port.ShouldBe(2222); + } + + [Fact] + public void TheRelayFlagIsInsideThePayload_SoItSurvivesARoundTrip() + { + // It has to be, or two clients could silently disagree about it and one would re-expose an + // address the other had just withdrawn. + var host = Host(relayEnabled: true); + var payload = HostCipher.Seal(host, vaultKey, HostA, 1, 1); + + HostCipher.TryOpen(payload, vaultKey, HostA, 1)!.Host.RelayEnabled.ShouldBeTrue(); + } + + private static HostSecret Host( + string label = "prod-db", + string hostname = "db.internal", + int port = 22, + bool relayEnabled = false) => + new() + { + Label = label, + Hostname = hostname, + Port = port, + Username = "deploy", + Options = HostOptions.Create([new HostOption("Compression", "yes")]), + RelayEnabled = relayEnabled, + }; +} diff --git a/tests/DodoSSH.Client.Sync.Tests/ResurrectionIdTests.cs b/tests/DodoSSH.Client.Sync.Tests/ResurrectionIdTests.cs new file mode 100644 index 0000000..6123677 --- /dev/null +++ b/tests/DodoSSH.Client.Sync.Tests/ResurrectionIdTests.cs @@ -0,0 +1,46 @@ +namespace DodoSSH.Client.Sync.Tests; + +/// +/// The id a rescued item takes. +/// +/// +/// Determinism here is what makes the rescue crash-safe. The reconciler queues the restored copy before +/// it clears the original, and those are two separate transactions — so a process that dies between them +/// leaves the original pending and resurrects again on the next pass. Landing on the same id means that +/// second attempt coalesces into the row already queued instead of leaving the user with duplicates. +/// +public sealed class ResurrectionIdTests +{ + private static readonly Guid Original = Guid.Parse("0192f0c8-1234-7c3d-8e4f-5a6b7c8d9e0f"); + + private static readonly Guid Other = Guid.Parse("0192f0c8-5678-7c3d-8e4f-5a6b7c8d9e0f"); + + [Fact] + public void TheSameTombstone_AlwaysYieldsTheSameId() + { + ResurrectionId.For(Original, 3).ShouldBe(ResurrectionId.For(Original, 3)); + } + + [Fact] + public void ADifferentItem_YieldsADifferentId() + { + ResurrectionId.For(Original, 3).ShouldNotBe(ResurrectionId.For(Other, 3)); + } + + [Fact] + public void ADifferentTombstoneVersion_YieldsADifferentId() + { + // An item deleted, restored, and deleted again must produce a second rescue rather than + // colliding with the first. + ResurrectionId.For(Original, 3).ShouldNotBe(ResurrectionId.For(Original, 4)); + } + + [Fact] + public void TheIdIsNotTheOriginal() + { + // The tombstone stands, so the rescued copy has to be a different item. Reusing the id would + // conflict against the tombstone for ever. + ResurrectionId.For(Original, 1).ShouldNotBe(Original); + ResurrectionId.For(Original, 1).ShouldNotBe(Guid.Empty); + } +} diff --git a/tests/DodoSSH.Client.Sync.Tests/SyncEngineTests.cs b/tests/DodoSSH.Client.Sync.Tests/SyncEngineTests.cs new file mode 100644 index 0000000..17b23ab --- /dev/null +++ b/tests/DodoSSH.Client.Sync.Tests/SyncEngineTests.cs @@ -0,0 +1,168 @@ +using static DodoSSH.Client.Sync.Tests.SyncHarness; + +namespace DodoSSH.Client.Sync.Tests; + +/// +/// The mechanics of a pass: paging, batching, bounds, and what the cursor is allowed to be. +/// +/// +/// Separate from the conflict matrix because the failure modes are different. Here a mistake shows up as +/// a sync that never finishes, or one that quietly stops halfway and reports success. +/// +public sealed class SyncEngineTests +{ + [Fact] + public async Task APullLargerThanOnePage_ReadsEveryChange() + { + // The server clamps a client's requested limit, so a client that trusted one response to be the + // whole story would silently see part of a vault. + using var harness = await CreateAsync( + new SyncOptions { PullPageSize = 2, MaxOperationsPerPush = 100 }); + + harness.Server.MaxPullLimit = 2; + + for (var index = 0; index < 7; index++) + { + await harness.First.CreateAsync(Host($"host-{index}")); + } + + await harness.First.SyncAsync(); + + var report = await harness.Second.SyncAsync(); + + report.Pulled.ShouldBe(7); + (await harness.Second.ListAsync()).Hosts.Count.ShouldBe(7); + } + + [Fact] + public async Task MoreQueuedChangesThanOneBatch_AreAllPushed() + { + using var harness = await CreateAsync(new SyncOptions { MaxOperationsPerPush = 2 }); + + for (var index = 0; index < 5; index++) + { + await harness.First.CreateAsync(Host($"host-{index}")); + } + + var report = await harness.First.SyncAsync(); + + report.Pushed.ShouldBe(5); + harness.Server.RowCount.ShouldBe(5); + + // Three rounds of two, so the drain loop genuinely continued rather than stopping at one batch. + harness.Server.PushCount.ShouldBeGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task AnExhaustedPushLoop_SaysSoRatherThanPretendingItFinished() + { + // A bound is necessary — each round advances, but against a vault someone else writes to + // continuously a pass could keep finding work. Reporting it is what stops that looking like + // success. + using var harness = await CreateAsync( + new SyncOptions { MaxOperationsPerPush = 1, MaxPushRounds = 2 }); + + for (var index = 0; index < 5; index++) + { + await harness.First.CreateAsync(Host($"host-{index}")); + } + + var report = await harness.First.SyncAsync(); + + report.RoundsExhausted.ShouldBeTrue(); + report.Pushed.ShouldBe(2); + + // And the rest is still queued, not lost. + (await harness.First.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken)) + .Count.ShouldBe(3); + + // A further pass picks up where this one stopped. + await harness.First.SyncAsync(); + await harness.First.SyncAsync(); + harness.Server.RowCount.ShouldBe(5); + } + + [Fact] + public async Task TheCursor_IsWhateverTheServerIssued() + { + // Opaque and integrity-tagged. The fake server rejects a cursor it did not mint, so a client that + // computed one would fail here rather than quietly resuming from a position it invented. + using var harness = await CreateAsync(); + + await harness.First.CreateAsync(Host("prod-db")); + await harness.First.SyncAsync(); + + var state = await harness.First.SyncState.ReadAsync( + VaultId, TestContext.Current.CancellationToken); + + state.Cursor.ShouldNotBeNull(); + state.Cursor.ShouldStartWith("fake-v1:"); + } + + [Fact] + public async Task AnEmptyPull_DoesNotMoveTheCursor() + { + // If it did, a write landing between this read and the next would be skipped for ever. + using var harness = await CreateAsync(); + + await harness.First.SyncAsync(); + + var before = await harness.First.SyncState.ReadAsync( + VaultId, TestContext.Current.CancellationToken); + + await harness.First.SyncAsync(); + + var after = await harness.First.SyncState.ReadAsync( + VaultId, TestContext.Current.CancellationToken); + + after.Cursor.ShouldBe(before.Cursor); + } + + [Fact] + public async Task ClockSkew_IsRecordedAndNotActedOn() + { + // Recorded because a user should be able to see it. Not acted on because the merge decides by + // version and retained ancestor — a skewed clock must not be able to pick a winner. + using var harness = await CreateAsync(); + + harness.Server.Now = TimeProvider.System.GetUtcNow().AddHours(3); + + var entityId = await harness.First.CreateAsync(Host("prod-db")); + var report = await harness.First.SyncAsync(); + + report.ServerTimeSkewMs.ShouldBeGreaterThan(2 * 60 * 60 * 1000); + + // The item still round-trips, so nothing downstream depended on the timestamp. + (await harness.First.FindAsync(entityId)).Host.Label.ShouldBe("prod-db"); + } + + [Fact] + public async Task ASyncWithNothingToDo_TouchesTheServerOnceAndReportsNothing() + { + using var harness = await CreateAsync(); + + var report = await harness.First.SyncAsync(); + + report.Pulled.ShouldBe(0); + report.Pushed.ShouldBe(0); + report.NeedsAttention.ShouldBeFalse(); + harness.Server.PushCount.ShouldBe(0); + } + + [Fact] + public async Task AVaultWithNoUsableGrant_IsReportedRatherThanRead() + { + // A grant awaiting re-wrap after a rekey. The vault is temporarily unreadable and saying so is + // the only honest answer — showing an empty host list would be indistinguishable from an empty + // vault. + using var harness = await CreateAsync(); + + var unknown = Guid.CreateVersion7(); + + harness.First.Keyring.CanRead(unknown).ShouldBeFalse(); + + await Should.ThrowAsync( + async () => await harness.First.Hosts.ListAsync( + unknown, TestContext.Current.CancellationToken)); + } +} diff --git a/tests/DodoSSH.Client.Sync.Tests/SyncHarness.cs b/tests/DodoSSH.Client.Sync.Tests/SyncHarness.cs new file mode 100644 index 0000000..0c5f493 --- /dev/null +++ b/tests/DodoSSH.Client.Sync.Tests/SyncHarness.cs @@ -0,0 +1,252 @@ +using DodoSSH.Client.Domain; +using DodoSSH.Client.Storage; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Sync.Tests; + +/// +/// One machine: its own cache, its own outbox, its own view of the vault. +/// +/// +/// A separate SQLite database per device, because the whole subject of these tests is two caches +/// diverging and being reconciled. Sharing one would make every conflict test vacuous. +/// +internal sealed class SyncDevice : IDisposable +{ + private readonly ClientCacheFactory factory; + private readonly MasterKey master; + private readonly LocalCacheProtector protector; + + private SyncDevice( + string name, + ClientCacheFactory factory, + MasterKey master, + LocalCacheProtector protector, + VaultKeyring keyring, + FakeVaultServer server, + SyncOptions options) + { + Name = name; + this.factory = factory; + this.master = master; + this.protector = protector; + Keyring = keyring; + + Items = new ItemStore(factory, protector); + Outbox = new OutboxStore(factory, protector, TimeProvider.System); + SyncState = new SyncStateStore(factory); + Conflicts = new ConflictStore(factory, protector, TimeProvider.System); + Hosts = new HostRepository(Items, Outbox, keyring); + + Engine = new SyncEngine( + server, Items, Outbox, SyncState, Conflicts, keyring, TimeProvider.System, options); + } + + internal string Name { get; } + + internal VaultKeyring Keyring { get; } + + internal ItemStore Items { get; } + + internal OutboxStore Outbox { get; } + + internal SyncStateStore SyncState { get; } + + internal ConflictStore Conflicts { get; } + + internal HostRepository Hosts { get; } + + internal SyncEngine Engine { get; } + + internal static async Task CreateAsync( + string name, + UserSecretBundle bundle, + StoredVault vault, + FakeVaultServer server, + SyncOptions options) + { + var cache = ClientCacheFactory.ForMemory($"sync-{name}-{Guid.CreateVersion7():N}"); + + try + { + await cache.MigrateAsync(TestContext.Current.CancellationToken); + + var derived = MasterKey.Derive( + $"passphrase-{name}", new byte[CryptoSpec.SaltSize], SyncHarness.CheapProfile); + + // Opened through the real grant, so the keyring, the wrap and the AAD are all exercised. + var keyring = VaultKeyring.Open(bundle, [vault]); + + return new SyncDevice( + name, cache, derived, LocalCacheProtector.From(derived), keyring, server, options); + } + catch + { + cache.Dispose(); + throw; + } + } + + internal Task SyncAsync() => + Engine.SyncAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken); + + internal Task ListAsync() => + Hosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken); + + internal async Task> HostsSortedAsync() + { + var listing = await ListAsync(); + + return [.. listing.Hosts.Select(h => h.Host).OrderBy(h => h.Label, StringComparer.Ordinal)]; + } + + internal async Task FindAsync(Guid entityId) + { + var listing = await ListAsync(); + + return listing.Hosts.SingleOrDefault(host => host.EntityId == entityId) + ?? throw new InvalidOperationException($"{Name} cannot see host {entityId}."); + } + + internal Task CreateAsync(HostSecret host) => + Hosts.CreateAsync(SyncHarness.VaultId, host, TestContext.Current.CancellationToken); + + internal Task UpdateAsync(Guid entityId, HostSecret host) => + Hosts.UpdateAsync(SyncHarness.VaultId, entityId, host, TestContext.Current.CancellationToken); + + internal Task DeleteAsync(Guid entityId) => + Hosts.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken); + + internal Task> ConflictsAsync() => + Conflicts.ListAsync(SyncHarness.VaultId, false, TestContext.Current.CancellationToken); + + /// + public void Dispose() + { + Keyring.Dispose(); + protector.Dispose(); + master.Dispose(); + factory.Dispose(); + } +} + +/// +/// One user, one vault, two machines and a server. +/// +/// +/// Both devices share the identity bundle, which is what a single user on a laptop and a desktop +/// actually looks like: one enrolled key pair, one vault grant, two independent local caches. That is +/// also the cheapest realistic setup in which every conflict case can be produced. +/// +internal sealed class SyncHarness : IDisposable +{ + internal static readonly Argon2Profile CheapProfile = + Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); + + private readonly UserSecretBundle bundle; + + private SyncHarness(UserSecretBundle bundle, FakeVaultServer server, SyncDevice first, SyncDevice second) + { + this.bundle = bundle; + Server = server; + First = first; + Second = second; + } + + internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-7777-7c3d-8e4f-5a6b7c8d9e0f"); + + internal FakeVaultServer Server { get; } + + /// The laptop. + internal SyncDevice First { get; } + + /// The desktop. + internal SyncDevice Second { get; } + + internal static async Task CreateAsync(SyncOptions? options = null) + { + var effective = options ?? SyncOptions.Default; + + var identity = UserSecretBundle.Create(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000)); + + try + { + var vaultKey = VaultKeys.Create(); + var wrapped = VaultKeys.WrapTo(vaultKey, identity.EncryptionPublicKey, VaultId, 1); + + // The plaintext key is not retained: each device unwraps the grant itself, as it would after + // an ordinary unlock. + System.Security.Cryptography.CryptographicOperations.ZeroMemory(vaultKey); + + var vault = new StoredVault( + VaultId, "Personal", IsPersonal: true, TeamId: null, KeyGeneration: 1, + Permissions: 31, wrapped, RekeyRequired: false); + + var server = new FakeVaultServer(VaultId); + + var first = await SyncDevice.CreateAsync("laptop", identity, vault, server, effective); + + try + { + var second = await SyncDevice.CreateAsync("desktop", identity, vault, server, effective); + return new SyncHarness(identity, server, first, second); + } + catch + { + first.Dispose(); + throw; + } + } + catch + { + identity.Dispose(); + throw; + } + } + + /// Brings both devices up to date, twice, so the result is a settled state. + /// + /// Twice because one pass per device is not enough for a change made on one to be merged on the + /// other and then pushed back. Asserting on a settled state rather than on an intermediate one is + /// what makes "the two devices converge" a meaningful claim. + /// + internal async Task SettleAsync() + { + for (var round = 0; round < 2; round++) + { + await First.SyncAsync(); + await Second.SyncAsync(); + } + } + + /// + public void Dispose() + { + First.Dispose(); + Second.Dispose(); + bundle.Dispose(); + } + + // ---- Builders ---- + + internal static HostSecret Host( + string label, + string hostname = "db.internal", + int port = 22, + string? username = "deploy", + string? notes = null, + (string Name, string Value)[]? options = null, + bool relayEnabled = false) => + new() + { + Label = label, + Hostname = hostname, + Port = port, + Username = username, + Notes = notes, + Options = options is null + ? HostOptions.Empty + : HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))), + RelayEnabled = relayEnabled, + }; +} diff --git a/tests/DodoSSH.Client.Sync.Tests/packages.lock.json b/tests/DodoSSH.Client.Sync.Tests/packages.lock.json new file mode 100644 index 0000000..e525b15 --- /dev/null +++ b/tests/DodoSSH.Client.Sync.Tests/packages.lock.json @@ -0,0 +1,447 @@ +{ + "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==" + }, + "NSubstitute": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "Shouldly": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==", + "dependencies": { + "DiffEngine": "11.3.0", + "EmptyFiles": "4.4.0" + } + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "DiffEngine": { + "type": "Transitive", + "resolved": "11.3.0", + "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==", + "dependencies": { + "EmptyFiles": "4.4.0", + "System.Management": "6.0.1" + } + }, + "EmptyFiles": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.10", + "Microsoft.EntityFrameworkCore.Relational": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "dodossh.client.api": { + "type": "Project", + "dependencies": { + "DodoSSH.Client.Auth": "[1.0.0, )", + "DodoSSH.Contracts": "[1.0.0, )", + "DodoSSH.Crypto": "[1.0.0, )" + } + }, + "dodossh.client.auth": { + "type": "Project" + }, + "dodossh.client.domain": { + "type": "Project" + }, + "dodossh.client.storage": { + "type": "Project", + "dependencies": { + "DodoSSH.Contracts": "[1.0.0, )", + "DodoSSH.Crypto": "[1.0.0, )", + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )" + } + }, + "dodossh.client.sync": { + "type": "Project", + "dependencies": { + "DodoSSH.Client.Api": "[1.0.0, )", + "DodoSSH.Client.Domain": "[1.0.0, )", + "DodoSSH.Client.Storage": "[1.0.0, )", + "DodoSSH.Contracts": "[1.0.0, )", + "DodoSSH.Crypto": "[1.0.0, )" + } + }, + "dodossh.contracts": { + "type": "Project" + }, + "dodossh.crypto": { + "type": "Project", + "dependencies": { + "NSec.Cryptography": "[26.4.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "NSec.Cryptography": { + "type": "CentralTransitive", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.12", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.12" + } + }, + "SQLitePCLRaw.core": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.12" + } + } + } + } +} \ No newline at end of file diff --git a/tests/DodoSSH.Contracts.Tests/SerializationTests.cs b/tests/DodoSSH.Contracts.Tests/SerializationTests.cs index acefac8..aed7458 100644 --- a/tests/DodoSSH.Contracts.Tests/SerializationTests.cs +++ b/tests/DodoSSH.Contracts.Tests/SerializationTests.cs @@ -20,11 +20,18 @@ public sealed class SerializationTests [Fact] public void Properties_AreCamelCase() { - var payload = new EncryptedPayload([1, 2, 3], KeyGeneration: 4, AadVersion: 1); + var payload = new EncryptedPayload( + [1, 2, 3], + WrappedDataKey: [4, 5], + DataKeyId: Guid.CreateVersion7(), + KeyGeneration: 4, + AadVersion: 1); var json = JsonSerializer.Serialize(payload, Options); json.ShouldContain("\"envelope\""); + json.ShouldContain("\"wrappedDataKey\""); + json.ShouldContain("\"dataKeyId\""); json.ShouldContain("\"keyGeneration\""); json.ShouldContain("\"aadVersion\""); } @@ -32,11 +39,16 @@ public sealed class SerializationTests [Fact] public void ByteArrays_AreBase64() { - var payload = new EncryptedPayload([0xDE, 0xAD, 0xBE, 0xEF], 1, 1); + var payload = new EncryptedPayload( + [0xDE, 0xAD, 0xBE, 0xEF], [0xC0, 0xFF, 0xEE], Guid.CreateVersion7(), 1, 1); var json = JsonSerializer.Serialize(payload, Options); json.ShouldContain(Convert.ToBase64String([0xDE, 0xAD, 0xBE, 0xEF])); + + // The data key wrap is a second envelope and must travel the same way. A string here + // instead would mean a client silently storing an item nobody can ever open. + json.ShouldContain(Convert.ToBase64String([0xC0, 0xFF, 0xEE])); } [Fact] @@ -64,13 +76,16 @@ public sealed class SerializationTests [Fact] public void EncryptedPayload_RoundTrips() { - var original = new EncryptedPayload([9, 8, 7, 6, 5], 12, 1); + var original = new EncryptedPayload( + [9, 8, 7, 6, 5], [1, 2, 3, 4], Guid.CreateVersion7(), 12, 1); var restored = JsonSerializer.Deserialize( JsonSerializer.Serialize(original, Options), Options); restored.ShouldNotBeNull(); restored.Envelope.ShouldBe(original.Envelope); + restored.WrappedDataKey.ShouldBe(original.WrappedDataKey); + restored.DataKeyId.ShouldBe(original.DataKeyId); restored.KeyGeneration.ShouldBe(original.KeyGeneration); restored.AadVersion.ShouldBe(original.AadVersion); } @@ -86,7 +101,7 @@ public sealed class SerializationTests Guid.CreateVersion7(), SyncOperation.Upsert, ExpectedVersion: 3, - Payload: new EncryptedPayload([1, 2, 3], 2, 1), + Payload: new EncryptedPayload([1, 2, 3], [4, 5], Guid.CreateVersion7(), 2, 1), PlaintextFields: new SyncPlaintextFields( RelayEnabled: true, Hostname: "bastion.internal", diff --git a/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs b/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs index 04a7037..f838246 100644 --- a/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs +++ b/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs @@ -91,6 +91,8 @@ public sealed class CryptoSpecTests [InlineData(CryptoSpec.AadResourceType.Snippet, 9)] [InlineData(CryptoSpec.AadResourceType.PortForward, 10)] [InlineData(CryptoSpec.AadResourceType.KnownHostKey, 11)] + [InlineData(CryptoSpec.AadResourceType.HostTag, 12)] + [InlineData(CryptoSpec.AadResourceType.HostCredential, 13)] public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected) { ((int)type).ShouldBe(expected);