diff --git a/DodoSSH.slnx b/DodoSSH.slnx
index 60c1c10..dd5dde5 100644
--- a/DodoSSH.slnx
+++ b/DodoSSH.slnx
@@ -20,6 +20,7 @@
+
@@ -29,8 +30,10 @@
+
+
diff --git a/README.md b/README.md
index 02023fa..ff3d022 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@ src/
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.Session where a profile lives, unlocking it, and getting one in the first place
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
@@ -104,11 +105,18 @@ 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, 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.
+ trust, the terminal data plane, the encrypted local cache with the sync client — offline unlock, an
+ outbox and a field-level three-way merge, conflict matrix green — and an Avalonia shell that is
+ vault-backed: server URL → browser sign-in → enroll → unlock → host list → terminal. The shell's whole
+ path is covered by tests against an in-memory server, so the states that matter most (the recovery code
+ that cannot be skipped, the unlock that needs no network) are checked rather than remembered.
+ *Remaining:* the manual end-to-end run against the real API and a real Keycloak from
+ `deploy/docker-compose.dev.yml`, which is what M1's definition of done actually asks for.
+
+ Known gaps in the client, stated rather than implied by the interface: credentials are not a synced
+ entity type yet, so a connection still asks for a password; known host keys live in memory for one
+ session instead of in the vault; and no device key is registered, so the passphrase is needed on every
+ launch until the OS keystore is wired.
- **M2 — full personal vault**, robust sync, relay.
- **M3 — teams**, sharing, ACLs.
- **M4 — hardening and ops**, packaging, self-hosting guide.
diff --git a/docs/platform-flags.md b/docs/platform-flags.md
index 9f93802..1565ef9 100644
--- a/docs/platform-flags.md
+++ b/docs/platform-flags.md
@@ -105,14 +105,18 @@ 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.*
+**The cache location is per-OS and must stay non-roaming.** `ClientPaths` chooses it:
+`%LOCALAPPDATA%\DodoSSH` on Windows, `~/Library/Application Support/DodoSSH` on macOS,
+`$XDG_DATA_HOME/dodossh` or `~/.local/share/dodossh` on Linux. It must **not** land anywhere that syncs
+to a cloud drive or roams: 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. That is also why Windows uses
+`%LOCALAPPDATA%` and not `%APPDATA%`, which roams in a domain environment.
+
+The platform branches are explicit rather than delegating to
+`Environment.SpecialFolder.LocalApplicationData` everywhere, because on macOS the runtime maps that to
+`~/.local/share` rather than to `~/Library/Application Support`. *Verified on Windows only* — the client
+created `%LOCALAPPDATA%\DodoSSH\cache.db` and migrated it on first launch. The macOS and Linux branches
+are reasoned, not run.
**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
@@ -120,12 +124,30 @@ throws at execution rather than at model build. `UnixMillisecondsConverter` is a
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.
+**The cache is three files, not one.** EF Core's SQLite provider puts the database in WAL mode, which is
+the right mode here — a background sync pass writes while the interface reads, and under the default
+rollback journal those reads would fail busy — but it means `cache.db` is accompanied by `cache.db-wal`
+and `cache.db-shm`. Any backup, export or uninstall routine that touches only `cache.db` is wrong.
+Verified by launching the client and reading `PRAGMA journal_mode`, after a comment in the code claimed
+the opposite.
+
+**Pooled SQLite connections keep the file open after the last context is disposed.** On Windows that
+means locked, so the application cannot delete or replace its own cache and a test cannot clean up after
+itself. `ClientCacheFactory.Dispose` clears the pool for exactly this reason; removing that line makes
+the failure appear only on Windows.
+
**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.
+**A `NativeWebView` that is never laid out is never realised.** The shell covers the terminal with its
+setup and unlock screens rather than collapsing it with `IsVisible`, because the control hosts a real
+child window and hiding it would leave the terminal blank on the first connection after unlocking.
+Verified on Windows: with the unlock overlay showing, `msedgewebview2` still had an established
+connection to the data plane port, so the page had loaded and completed its WebSocket handshake.
+
## Build and CI
**Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI.
diff --git a/src/DodoSSH.Client.Api/ClientEnrollment.cs b/src/DodoSSH.Client.Api/ClientEnrollment.cs
index f2774c8..904c182 100644
--- a/src/DodoSSH.Client.Api/ClientEnrollment.cs
+++ b/src/DodoSSH.Client.Api/ClientEnrollment.cs
@@ -15,8 +15,9 @@ namespace DodoSSH.Client.Api;
/// The identity key pair, unlocked for this session.
/// The personal vault's key, in plaintext for this session.
///
-/// The enrolled device's X25519 private key. Belongs in the OS keystore — it is what lets a later
-/// launch unlock without the passphrase.
+/// The enrolled device's X25519 private key, or when no device was bound.
+/// Belongs in the OS keystore — it is what lets a later launch unlock without the passphrase, and it
+/// is the only thing that can open the device wrap held server-side.
///
///
/// The generated recovery code, which must be shown to the user once and never stored. Losing this
@@ -27,7 +28,7 @@ public sealed record EnrollmentOutcome(
EnrollmentResponse Response,
UserSecretBundle Bundle,
byte[] PersonalVaultKey,
- byte[] DevicePrivateKey,
+ byte[]? DevicePrivateKey,
string RecoveryCode);
///
@@ -46,10 +47,19 @@ public sealed record EnrollmentOutcome(
///
///
public sealed class ClientEnrollment(
- DodoSshApiClient api,
+ IAccountApi api,
IKeyBindingAuthorizer keyBinding,
- TimeProvider clock)
+ TimeProvider clock,
+ Argon2Profile? passphraseProfile = null)
{
+ ///
+ /// Configurable because the cost is a product decision, not a constant: the plan exposes 128, 256 and
+ /// 512 MiB security levels, and the parameters travel with the wrap so a user's choice is theirs
+ /// alone. It also lets a test pay milliseconds instead of a third of a second to prove something that
+ /// has nothing to do with how hard the passphrase is to attack.
+ ///
+ private readonly Argon2Profile passphraseProfile = passphraseProfile ?? Argon2Profile.PassphraseDefault;
+
/// Bytes of entropy behind a recovery code.
private const int RecoveryEntropyBytes = 20;
@@ -63,12 +73,22 @@ public sealed class ClientEnrollment(
/// The vault passphrase. Never transmitted or stored.
/// Human-readable name for this machine.
/// Display name for the personal vault. Plaintext, as vault names are.
+ ///
+ /// Whether to register a device key so a later launch can unlock without the passphrase.
+ ///
+ /// Pass when the caller has nowhere durable to keep the private half. A
+ /// device wrap whose private key does not survive the process is a row on the server that nobody can
+ /// ever open, and it makes the account's device list claim a capability this machine does not have —
+ /// which is worse than not offering it.
+ ///
+ ///
/// Cancellation token.
public async Task EnrollAsync(
MeResponse me,
string passphrase,
string deviceName,
string vaultName,
+ bool bindThisDevice,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(me);
@@ -91,7 +111,8 @@ public sealed class ClientEnrollment(
.ConfigureAwait(false);
var request = BuildRequest(
- me, bundle, statement, idToken, passphrase, vaultName, now, out var material);
+ me, bundle, statement, idToken, passphrase, passphraseProfile, vaultName,
+ bindThisDevice, now, out var material);
var response = await api.EnrollAsync(request, cancellationToken).ConfigureAwait(false);
@@ -130,7 +151,7 @@ public sealed class ClientEnrollment(
/// Secrets the caller keeps after a successful enrollment.
private readonly record struct SessionMaterial(
byte[] VaultKey,
- byte[] DevicePrivateKey,
+ byte[]? DevicePrivateKey,
string RecoveryCode);
///
@@ -143,7 +164,9 @@ public sealed class ClientEnrollment(
KeyStatement statement,
string idToken,
string passphrase,
+ Argon2Profile passphraseProfile,
string vaultName,
+ bool bindThisDevice,
DateTimeOffset now,
out SessionMaterial material)
{
@@ -158,8 +181,7 @@ public sealed class ClientEnrollment(
byte[] passphraseWrap;
byte[] recoveryWrap;
- using (var master = MasterKey.Derive(
- passphrase, passphraseSalt, Argon2Profile.PassphraseDefault))
+ using (var master = MasterKey.Derive(passphrase, passphraseSalt, passphraseProfile))
{
passphraseWrap = master.WrapBundle(bundle, descriptor);
}
@@ -171,19 +193,24 @@ public sealed class ClientEnrollment(
recoveryWrap = recoveryMaster.WrapBundle(bundle, descriptor);
}
- using var deviceKey = Key.Create(
- KeyAgreementAlgorithm.X25519,
- new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
+ byte[]? devicePublicKey = null;
+ byte[]? deviceWrap = null;
+ byte[]? devicePrivateKey = null;
- var devicePublicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
- var deviceWrap = bundle.SealTo(devicePublicKey, descriptor);
+ if (bindThisDevice)
+ {
+ using var deviceKey = Key.Create(
+ KeyAgreementAlgorithm.X25519,
+ new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
+
+ devicePublicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
+ deviceWrap = bundle.SealTo(devicePublicKey, descriptor);
+ devicePrivateKey = deviceKey.Export(KeyBlobFormat.RawPrivateKey);
+ }
var vault = BuildPersonalVault(me, bundle, vaultName, now, out var vaultKey);
- material = new SessionMaterial(
- vaultKey,
- deviceKey.Export(KeyBlobFormat.RawPrivateKey),
- recoveryCode);
+ material = new SessionMaterial(vaultKey, devicePrivateKey, recoveryCode);
return new EnrollmentRequest(
Statement: statement,
@@ -192,7 +219,7 @@ public sealed class ClientEnrollment(
KeyStatementCodec.Encode(ToFields(statement))),
IdentityProviderToken: idToken,
WrappedPrivateKey: passphraseWrap,
- KdfParameters: ToContract(passphraseSalt, Argon2Profile.PassphraseDefault),
+ KdfParameters: ToContract(passphraseSalt, passphraseProfile),
DevicePublicKey: devicePublicKey,
DeviceWrappedPrivateKey: deviceWrap,
RecoveryWrappedPrivateKey: recoveryWrap,
diff --git a/src/DodoSSH.Client.Api/DodoSshApiClient.cs b/src/DodoSSH.Client.Api/DodoSshApiClient.cs
index 54fdb9c..ca7f5aa 100644
--- a/src/DodoSSH.Client.Api/DodoSshApiClient.cs
+++ b/src/DodoSSH.Client.Api/DodoSshApiClient.cs
@@ -18,6 +18,24 @@ public interface IAccessTokenProvider
ValueTask GetAccessTokenAsync(CancellationToken cancellationToken);
}
+///
+/// The account calls: who am I, and publish my first key.
+///
+///
+/// Separated for the same reason as . What the session layer does with these is
+/// decide between enrolling and unlocking, and persist the result so the next launch needs no network;
+/// testing that against a stubbed HTTP transport would prove the right bytes were sent and nothing
+/// about the decision.
+///
+public interface IAccountApi
+{
+ /// Reads the caller's profile, unlock material and reachable vaults.
+ Task GetMeAsync(CancellationToken cancellationToken);
+
+ /// Publishes the caller's first identity key and creates their personal vault.
+ Task EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken);
+}
+
///
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
///
@@ -58,7 +76,8 @@ public interface ISyncApi
/// Everything else carries a bearer token.
///
///
-public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) : ISyncApi
+public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
+ : IAccountApi, ISyncApi
{
private const string MetaPath = "/api/v1/meta";
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
diff --git a/src/DodoSSH.Client.App/App.axaml.cs b/src/DodoSSH.Client.App/App.axaml.cs
index 059079d..4edf364 100644
--- a/src/DodoSSH.Client.App/App.axaml.cs
+++ b/src/DodoSSH.Client.App/App.axaml.cs
@@ -4,7 +4,10 @@ using Avalonia.Markup.Xaml;
using DodoSSH.Client.App.Terminal;
using DodoSSH.Client.App.ViewModels;
using DodoSSH.Client.App.Views;
+using DodoSSH.Client.Auth;
+using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
+using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App;
@@ -34,16 +37,24 @@ internal sealed partial class DodoSshApp : Application
}
///
- /// Composed by hand rather than through a container. The graph is four objects deep, and an
- /// indirection to read through would buy nothing at this size.
///
- /// The workspace is a local captured by the closures below rather than a field, so this type does
- /// not own a disposable it has no good place to dispose — an Avalonia Application has no
- /// disposal hook of its own.
+ /// Composed by hand rather than through a container. The graph is a handful of objects deep and an
+ /// indirection to read through would buy nothing at this size.
+ ///
+ ///
+ /// Everything disposable is a local captured by the closures below rather than a field, because an
+ /// Avalonia Application has no disposal hook of its own and a type that owned them would have
+ /// nowhere honest to release them.
///
///
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
{
+ var paths = ClientPaths.Default;
+ var caches = ClientCacheFactory.ForFile(paths.CacheFile);
+
+ // Known hosts are still in memory. The plan puts them in the vault as a synced entity so trust
+ // follows the user to every device, and SyncEntityType.KnownHostKey is reserved for it — but that
+ // entity type is not synced yet, so trust currently lasts one session.
var knownHosts = new InMemoryKnownHostStore();
var workspace = new TerminalWorkspace(
@@ -53,16 +64,30 @@ internal sealed partial class DodoSshApp : Application
workspace.Start();
- desktop.MainWindow = new MainWindow
- {
- DataContext = new MainWindowViewModel(workspace, knownHosts),
- };
+ var browser = new SystemBrowserLauncher();
+
+ var viewModel = new MainWindowViewModel(
+ paths,
+ caches,
+ workspace,
+ knownHosts,
+ async (url, cancellationToken) => await ServerConnection
+ .SignInAsync(url, browser, TimeProvider.System, cancellationToken)
+ .ConfigureAwait(false),
+ TimeProvider.System);
+
+ desktop.MainWindow = new MainWindow { DataContext = viewModel };
+
+ // Started rather than awaited: the framework's initialisation must not block on a schema
+ // migration. The view model shows its own progress and handles its own failures, which is why
+ // discarding the task here is safe rather than merely convenient.
+ _ = viewModel.StartAsync(CancellationToken.None);
var shuttingDown = false;
// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening
- // socket, and blocking the UI thread on their disposal is how an application comes to take
- // several seconds to close — or deadlocks, if any of that disposal needs the UI thread.
+ // socket, and blocking the UI thread on their disposal is how an application comes to take several
+ // seconds to close — or deadlocks, if any of that disposal needs the UI thread.
desktop.ShutdownRequested += async (_, e) =>
{
if (shuttingDown)
@@ -73,8 +98,13 @@ internal sealed partial class DodoSshApp : Application
shuttingDown = true;
e.Cancel = true;
+ // The view model first: it holds the vault session, and disposing that is what zeroes the
+ // identity keys, the vault keys and the cache key.
+ await viewModel.DisposeAsync().ConfigureAwait(true);
await workspace.DisposeAsync().ConfigureAwait(true);
+ caches.Dispose();
+
desktop.Shutdown();
};
}
diff --git a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
index 269d39c..1e0272f 100644
--- a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
+++ b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
@@ -25,10 +25,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/src/DodoSSH.Client.App/packages.lock.json b/src/DodoSSH.Client.App/packages.lock.json
index 873fdba..e3c50e9 100644
--- a/src/DodoSSH.Client.App/packages.lock.json
+++ b/src/DodoSSH.Client.App/packages.lock.json
@@ -191,19 +191,116 @@
"resolved": "0.11.6",
"contentHash": "NdNWGDiZ6eS/Mf/9+QHR91cj1K7Hy+PX9yrHI/zM7xFYuj9IWT2uxtB6sCHjrnxAeLV9fut1R6zHDUGKX6f9lQ=="
},
+ "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": "8.0.2",
- "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
+ "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": "8.0.3",
- "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
+ "resolved": "10.0.10",
+ "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
+ "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=="
+ },
"SkiaSharp": {
"type": "Transitive",
"resolved": "3.119.4",
@@ -238,24 +335,172 @@
"resolved": "0.94.1",
"contentHash": "11YMr7FnAbL83bQmVxlhbIKHvSLxjO81D12Ej0QMSGXMDTxNA9MTOa4MQxx43nv5el/efuPHwzyrj6a5ha2gug=="
},
+ "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.session": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Api": "[1.0.0, )",
+ "DodoSSH.Client.Auth": "[1.0.0, )",
+ "DodoSSH.Client.Domain": "[1.0.0, )",
+ "DodoSSH.Client.Storage": "[1.0.0, )",
+ "DodoSSH.Client.Sync": "[1.0.0, )"
+ }
+ },
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"SSH.NET": "[2025.1.0, )"
}
},
+ "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.client.terminal": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
+ "dodossh.contracts": {
+ "type": "Project"
+ },
+ "dodossh.crypto": {
+ "type": "Project",
+ "dependencies": {
+ "NSec.Cryptography": "[26.4.0, )"
+ }
+ },
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
+ "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"
+ }
+ },
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
diff --git a/src/DodoSSH.Client.Session/AccountProvisioner.cs b/src/DodoSSH.Client.Session/AccountProvisioner.cs
new file mode 100644
index 0000000..bf5e4b5
--- /dev/null
+++ b/src/DodoSSH.Client.Session/AccountProvisioner.cs
@@ -0,0 +1,200 @@
+using System.Security.Cryptography;
+using DodoSSH.Client.Api;
+using DodoSSH.Client.Auth;
+using DodoSSH.Client.Storage;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Session;
+
+/// What the server said about this account.
+public enum ProvisionStatus
+{
+ /// Not a legal value.
+ Unspecified = 0,
+
+ ///
+ /// No identity key exists yet. The user must choose a passphrase and enroll before anything else
+ /// works.
+ ///
+ EnrollmentRequired = 1,
+
+ /// Enrolled, and everything an offline unlock needs is now cached.
+ Ready = 2,
+}
+
+/// The result of talking to the server about this account.
+/// What happened.
+/// The profile the server reported.
+///
+/// Present only immediately after enrolling. Must be shown once and never stored. Losing this
+/// along with the passphrase and every enrolled device means the vault is unrecoverable, and there is no
+/// server-side reset by design — see docs/crypto.md §10.
+///
+/// Something to show the user.
+public sealed record ProvisionOutcome(
+ ProvisionStatus Status,
+ MeResponse Me,
+ string? RecoveryCode,
+ string Message);
+
+///
+/// Gets this machine from "signed in" to "has everything an offline unlock needs".
+///
+///
+///
+/// The only part of the client that requires a network. Everything it does is in service of the part
+/// that does not: it caches the KDF salt, the wrapped identity bundle and the vault grants, so every
+/// later launch opens the vault with nothing but the passphrase.
+///
+///
+/// After enrolling it re-reads /me rather than caching what it believes it sent. That is a
+/// deliberate round trip: it proves the server stored what this client thinks it did, and the passphrase
+/// the user just chose is then verified against the cached wrap on the very next unlock rather than on
+/// some future launch when they have forgotten which one they typed.
+///
+///
+public sealed class AccountProvisioner(
+ IAccountApi api,
+ IKeyBindingAuthorizer keyBinding,
+ ClientCacheFactory caches,
+ TimeProvider clock,
+ Argon2Profile? passphraseProfile = null)
+{
+ /// Reads the account and caches whatever an offline unlock will need.
+ public async Task RefreshAsync(
+ string serverUrl,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);
+
+ var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
+
+ if (me.EnrollmentRequired)
+ {
+ return new ProvisionOutcome(
+ ProvisionStatus.EnrollmentRequired,
+ me,
+ null,
+ "This account has no vault key yet. Choose a passphrase to create one.");
+ }
+
+ await CacheAsync(serverUrl, me, cancellationToken).ConfigureAwait(false);
+
+ return new ProvisionOutcome(
+ ProvisionStatus.Ready, me, null, "Signed in. Unlock with your vault passphrase.");
+ }
+
+ ///
+ /// Creates this account's identity key and personal vault.
+ ///
+ /// The server this profile belongs to.
+ /// The vault passphrase. Never transmitted or stored.
+ /// Human-readable name for this machine, shown in the key statement.
+ /// Display name for the personal vault.
+ /// Cancellation token.
+ ///
+ /// No device key is registered. Its private half belongs in the OS keystore, and nothing wires
+ /// one up yet — so registering it would put a wrap on the server that no key can open and would make
+ /// the account's device list claim this machine can unlock without a passphrase. Until the keystore
+ /// is wired, the passphrase is required on every launch. That is a limitation, not a design choice.
+ ///
+ public async Task EnrollAsync(
+ string serverUrl,
+ string passphrase,
+ string deviceName,
+ string vaultName,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);
+ ArgumentException.ThrowIfNullOrEmpty(passphrase);
+
+ var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
+
+ if (!me.EnrollmentRequired)
+ {
+ // Someone else already enrolled this account — another machine, or a retry whose answer was
+ // lost. Caching what is there is the right move; re-enrolling would replace a key other
+ // people may already have wrapped vault keys to.
+ await CacheAsync(serverUrl, me, cancellationToken).ConfigureAwait(false);
+
+ return new ProvisionOutcome(
+ ProvisionStatus.Ready,
+ me,
+ null,
+ "This account was already enrolled. Unlock with your existing vault passphrase.");
+ }
+
+ var enrollment = new ClientEnrollment(api, keyBinding, clock, passphraseProfile);
+
+ var outcome = await enrollment
+ .EnrollAsync(me, passphrase, deviceName, vaultName, bindThisDevice: false, cancellationToken)
+ .ConfigureAwait(false);
+
+ try
+ {
+ var enrolled = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
+
+ await CacheAsync(serverUrl, enrolled, cancellationToken).ConfigureAwait(false);
+
+ return new ProvisionOutcome(
+ ProvisionStatus.Ready,
+ enrolled,
+ outcome.RecoveryCode,
+ "Your vault was created. Write the recovery code down before continuing.");
+ }
+ finally
+ {
+ // The session keys are re-derived from the cached wrap at unlock, so nothing here needs to
+ // survive this method — and a vault key left in a managed array is a vault key in a heap dump.
+ outcome.Bundle.Dispose();
+ CryptographicOperations.ZeroMemory(outcome.PersonalVaultKey);
+ }
+ }
+
+ ///
+ /// Both halves matter. Without the unlock material there is no offline unlock; without the vault
+ /// grants an offline launch could open the identity bundle and still not decrypt a single item.
+ ///
+ private async Task CacheAsync(
+ string serverUrl,
+ MeResponse me,
+ CancellationToken cancellationToken)
+ {
+ if (me.WrappedPrivateKey is null || me.KdfParameters is null || me.KeyGeneration is null)
+ {
+ throw new InvalidOperationException(
+ "The server reported an enrolled account without the material needed to unlock it. "
+ + "Refusing to cache a profile that could never be opened.");
+ }
+
+ await new UnlockStore(caches, clock).SaveAsync(
+ new StoredUnlockMaterial(
+ serverUrl,
+ me.UserId,
+ me.Issuer,
+ me.Subject,
+ me.Email,
+ me.DisplayName,
+ (uint)me.KeyGeneration.Value,
+ me.WrappedPrivateKey,
+ me.KdfParameters,
+ clock.GetUtcNow()),
+ cancellationToken).ConfigureAwait(false);
+
+ await new VaultStore(caches, clock).ReplaceAllAsync(
+ [.. me.Vaults.Select(ToStored)],
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private static StoredVault ToStored(VaultSummary summary) =>
+ new(
+ summary.VaultId,
+ summary.Name,
+ summary.IsPersonal,
+ summary.TeamId,
+ summary.KeyGeneration,
+ summary.Permissions,
+ summary.WrappedVaultKey,
+ summary.RekeyRequired);
+}
diff --git a/src/DodoSSH.Client.Session/ClientPaths.cs b/src/DodoSSH.Client.Session/ClientPaths.cs
new file mode 100644
index 0000000..874f820
--- /dev/null
+++ b/src/DodoSSH.Client.Session/ClientPaths.cs
@@ -0,0 +1,74 @@
+namespace DodoSSH.Client.Session;
+
+///
+/// Where this machine keeps its profile.
+///
+///
+///
+/// A record with an explicit directory rather than a static lookup, so a test — or a portable install —
+/// can point it somewhere else without an environment variable.
+///
+///
+/// The choice of directory matters more than it looks. The cache is a SQLite file written by one
+/// process, and the whole design assumes each machine has its own: the outbox holds changes this machine
+/// has made and not yet pushed, and two machines sharing one file through a cloud sync client corrupts
+/// it. So this deliberately picks a local, non-roaming location on every platform. On Windows
+/// that means %LOCALAPPDATA% and never %APPDATA%, which roams in a domain environment and
+/// would do exactly the wrong thing.
+///
+///
+/// The profile directory. Created on demand.
+public sealed record ClientPaths(string DataDirectory)
+{
+ private const string WindowsFolderName = "DodoSSH";
+ private const string UnixFolderName = "dodossh";
+
+ /// The conventional location for this platform.
+ public static ClientPaths Default { get; } = new(ResolveDataDirectory());
+
+ /// The encrypted local cache.
+ public string CacheFile => Path.Combine(DataDirectory, "cache.db");
+
+ /// Creates the profile directory if it is not there yet.
+ ///
+ /// Separate from resolving the path, because resolving must never have a side effect: it is read
+ /// during startup diagnostics and by tests that have no business creating directories.
+ ///
+ public void EnsureCreated() => Directory.CreateDirectory(DataDirectory);
+
+ ///
+ /// The platform branches are explicit rather than delegating to
+ /// everywhere. That enumeration does
+ /// the right thing on Windows, but on macOS the runtime maps it to ~/.local/share rather than
+ /// to ~/Library/Application Support, and relying on framework behaviour that differs per
+ /// platform for a path users will look at is how a file ends up somewhere nobody expects.
+ ///
+ /// XDG_DATA_HOME is honoured explicitly for the same reason: it is the spec, and reading it
+ /// here is one line versus depending on whether the runtime happens to.
+ ///
+ ///
+ private static string ResolveDataDirectory()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ WindowsFolderName);
+ }
+
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+
+ if (OperatingSystem.IsMacOS())
+ {
+ return Path.Combine(home, "Library", "Application Support", WindowsFolderName);
+ }
+
+ var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
+
+ var root = string.IsNullOrWhiteSpace(xdgDataHome)
+ ? Path.Combine(home, ".local", "share")
+ : xdgDataHome;
+
+ return Path.Combine(root, UnixFolderName);
+ }
+}
diff --git a/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj b/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj
new file mode 100644
index 0000000..bb4c342
--- /dev/null
+++ b/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Session/ServerConnection.cs b/src/DodoSSH.Client.Session/ServerConnection.cs
new file mode 100644
index 0000000..566eb1c
--- /dev/null
+++ b/src/DodoSSH.Client.Session/ServerConnection.cs
@@ -0,0 +1,262 @@
+using DodoSSH.Client.Api;
+using DodoSSH.Client.Auth;
+using DodoSSH.Client.Sync;
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Client.Session;
+
+///
+/// Stands in for a token provider before anyone has signed in.
+///
+///
+/// Discovery has to happen before authentication is possible — a client cannot know how to authenticate
+/// until it has asked — but the API client takes a token provider in its constructor. Rather than make
+/// that provider mutable and hope no authenticated call slips through early, the unauthenticated phase
+/// gets a provider that says exactly what went wrong.
+///
+internal sealed class UnavailableAccessTokenProvider : IAccessTokenProvider
+{
+ internal static UnavailableAccessTokenProvider Instance { get; } = new();
+
+ public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken) =>
+ throw new InvalidOperationException(
+ "An authenticated call was attempted before sign-in. Only /meta and the discovery document "
+ + "are reachable at this point.");
+}
+
+///
+/// Keeps the bearer token fresh for the life of a connection.
+///
+///
+/// The refresh happens under a lock with the expiry re-checked inside it. Without that second check,
+/// several concurrent calls all decide the token is stale and all refresh — and because many providers
+/// rotate the refresh token on use, every attempt after the first fails, turning one expiry into a forced
+/// re-authentication.
+///
+internal sealed class RefreshingAccessTokenProvider(
+ OidcClient oidc,
+ TokenSet initial,
+ TimeProvider clock) : IAccessTokenProvider, IDisposable
+{
+ private readonly SemaphoreSlim gate = new(1, 1);
+ private TokenSet tokens = initial;
+
+ public async ValueTask GetAccessTokenAsync(CancellationToken cancellationToken)
+ {
+ if (!tokens.NeedsRefresh(clock))
+ {
+ return tokens.AccessToken;
+ }
+
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ if (!tokens.NeedsRefresh(clock))
+ {
+ return tokens.AccessToken;
+ }
+
+ if (tokens.RefreshToken is null)
+ {
+ throw new InvalidOperationException(
+ "The access token has expired and no refresh token was granted. Sign in again.");
+ }
+
+ tokens = await oidc.RefreshAsync(tokens.RefreshToken, cancellationToken)
+ .ConfigureAwait(false);
+
+ return tokens.AccessToken;
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ public void Dispose() => gate.Dispose();
+}
+
+///
+/// What a signed-in server offers, as everything above the session layer needs it.
+///
+///
+/// An interface rather than the concrete connection, for one specific reason: establishing a real one
+/// requires discovery, a browser and a token exchange. A shell that depended on the concrete type would
+/// make its own state machine — sign in, enroll, unlock, sync — reachable only by clicking through an
+/// identity provider, which is the part of an application that most needs a test and least often has one.
+///
+public interface IVaultServer : IDisposable
+{
+ /// The server this is connected to.
+ Uri ServerUrl { get; }
+
+ /// Who am I, and publish my first key.
+ IAccountApi Account { get; }
+
+ /// Pull and push.
+ ISyncApi Sync { get; }
+
+ /// Obtains the identity provider's signature over a key statement.
+ IKeyBindingAuthorizer KeyBinding { get; }
+
+ /// Sync tuning derived from what this server actually accepts.
+ SyncOptions SyncOptions { get; }
+}
+
+///
+/// A signed-in connection to one DodoSSH server.
+///
+///
+///
+/// The onboarding story in one object: the user types a server URL, the client reads
+/// /.well-known/dodossh-configuration to learn the identity provider, the client id and the
+/// scopes, and everything else follows. Nothing about the identity provider is configured on this
+/// machine.
+///
+///
+/// A session outlives this. Losing the network invalidates the connection, not the vault — which is why
+/// syncing takes an per call rather than the session holding one.
+///
+///
+public sealed class ServerConnection : IVaultServer
+{
+ private readonly HttpClient http;
+ private readonly RefreshingAccessTokenProvider tokens;
+ private bool disposed;
+
+ private ServerConnection(
+ Uri serverUrl,
+ HttpClient http,
+ DodoSshConfiguration configuration,
+ MetaResponse meta,
+ OidcClient oidc,
+ RefreshingAccessTokenProvider tokens,
+ DodoSshApiClient api)
+ {
+ ServerUrl = serverUrl;
+ this.http = http;
+ Configuration = configuration;
+ Meta = meta;
+ Oidc = oidc;
+ this.tokens = tokens;
+ Api = api;
+ }
+
+ /// The server this is connected to.
+ public Uri ServerUrl { get; }
+
+ /// What the server told us about itself and its identity provider.
+ public DodoSshConfiguration Configuration { get; }
+
+ /// Versions, features and limits.
+ public MetaResponse Meta { get; }
+
+ /// The identity provider client, which is also the key-binding authorizer.
+ public OidcClient Oidc { get; }
+
+ /// The authenticated API client.
+ public DodoSshApiClient Api { get; }
+
+ ///
+ public IAccountApi Account => Api;
+
+ ///
+ public ISyncApi Sync => Api;
+
+ ///
+ public IKeyBindingAuthorizer KeyBinding => Oidc;
+
+ ///
+ /// Sync tuning derived from what this server actually accepts.
+ ///
+ ///
+ /// This is what capability negotiation is for, and why there is no URL API version. A client and a
+ /// server that upgrade independently — normal for self-hosted software — have to agree on limits by
+ /// asking rather than by assuming. Sending a batch larger than the server's cap would have the whole
+ /// push rejected rather than the excess trimmed.
+ ///
+ ///
+ public SyncOptions SyncOptions => new()
+ {
+ MaxOperationsPerPush = Math.Clamp(Meta.MaxOperationsPerPush, 1, 500),
+ };
+
+ ///
+ /// Discovers the server, signs the user in through their browser, and returns the connection.
+ ///
+ /// The DodoSSH server's base URL — the only thing the user has to know.
+ /// Opens the system browser. Never an embedded one; see RFC 8252.
+ /// Time source, for token expiry.
+ /// Cancels the wait for the browser.
+ public static async Task SignInAsync(
+ Uri serverUrl,
+ IBrowserLauncher browser,
+ TimeProvider clock,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(serverUrl);
+ ArgumentNullException.ThrowIfNull(browser);
+ ArgumentNullException.ThrowIfNull(clock);
+
+ var transport = new HttpClient { BaseAddress = serverUrl };
+
+ try
+ {
+ var discovery = new DodoSshApiClient(transport, UnavailableAccessTokenProvider.Instance);
+
+ var configuration = await discovery.GetConfigurationAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var meta = await discovery.GetMetaAsync(cancellationToken).ConfigureAwait(false);
+
+ var oidc = new OidcClient(transport, browser, clock, BuildOidcOptions(configuration));
+
+ var tokenSet = await oidc.SignInAsync(cancellationToken).ConfigureAwait(false);
+
+ var refreshing = new RefreshingAccessTokenProvider(oidc, tokenSet, clock);
+
+ return new ServerConnection(
+ serverUrl,
+ transport,
+ configuration,
+ meta,
+ oidc,
+ refreshing,
+ new DodoSshApiClient(transport, refreshing));
+ }
+ catch
+ {
+ transport.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ tokens.Dispose();
+ http.Dispose();
+ }
+
+ ///
+ /// HTTPS is required for the provider's metadata unless the authority is loopback, which is what a
+ /// development Keycloak looks like. A configuration flag would be the alternative and a worse one:
+ /// it would be set once during development and never unset. Loopback is not a weaker channel — it
+ /// never leaves the machine — so the exemption is narrow and does not need a switch.
+ ///
+ private static OidcClientOptions BuildOidcOptions(DodoSshConfiguration configuration) =>
+ new()
+ {
+ Authority = configuration.Oidc.Authority,
+ ClientId = configuration.Oidc.ClientId,
+ Scopes = configuration.Oidc.Scopes,
+ RequireHttpsMetadata = !configuration.Oidc.Authority.IsLoopback,
+ };
+}
diff --git a/src/DodoSSH.Client.Session/SessionOpener.cs b/src/DodoSSH.Client.Session/SessionOpener.cs
new file mode 100644
index 0000000..d6321c1
--- /dev/null
+++ b/src/DodoSSH.Client.Session/SessionOpener.cs
@@ -0,0 +1,243 @@
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Sync;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Session;
+
+/// Why an unlock did or did not produce a session.
+public enum UnlockStatus
+{
+ /// Not a legal value.
+ Unspecified = 0,
+
+ /// The vault is open.
+ Unlocked = 1,
+
+ ///
+ /// This machine has never been enrolled, so there is nothing here to unlock. The user has to sign in
+ /// to a server first, which needs a network.
+ ///
+ NotEnrolled = 2,
+
+ ///
+ /// The passphrase did not open the wrap.
+ ///
+ ///
+ /// The overwhelmingly common failure, and a return value rather than an exception for that reason.
+ /// It is also indistinguishable from a tampered wrap, which is correct: the AEAD tag is the only
+ /// evidence either way, and no passphrase verifier is stored anywhere. See docs/crypto.md §2.
+ ///
+ WrongPassphrase = 3,
+
+ ///
+ /// The identity opened but no vault grant did, so there is nothing readable.
+ ///
+ ///
+ /// What a rekey looks like before new grants arrive. Distinguished from a wrong passphrase because
+ /// the remedy is completely different — this one needs a member with Share to finish the rekey, and
+ /// telling the user to retype their passphrase would be actively misleading.
+ ///
+ NoReadableVault = 4,
+
+ /// The cached KDF parameters are not something this build can use.
+ UnsupportedKdf = 5,
+}
+
+/// The result of an unlock attempt.
+/// What happened.
+/// The open vault, present only when is unlocked.
+/// Something to show the user. Never contains secret material.
+public sealed record UnlockOutcome(UnlockStatus Status, VaultSession? Session, string Message)
+{
+ /// Whether a session came back.
+ public bool IsUnlocked => Status == UnlockStatus.Unlocked && Session is not null;
+}
+
+///
+/// Opens the vault from what is already on this machine.
+///
+///
+///
+/// This path touches no network, deliberately and testably. The Argon2id salt, its cost
+/// parameters and the wrapped identity bundle are all cached at enrollment, so deriving the master key
+/// and opening the bundle need nothing but the passphrase. Fetching any of it at unlock time would make
+/// an offline launch impossible, which is the single most common moment a user actually needs their
+/// hosts.
+///
+///
+/// Nothing derived here is persisted. The master key exists for the duration of this method and is
+/// zeroed before it returns; what survives is the cache subkey and the identity keys, in the session,
+/// until the session is disposed.
+///
+///
+public sealed class SessionOpener(
+ ClientCacheFactory caches,
+ TimeProvider clock,
+ SyncOptions? options = null)
+{
+ private readonly SyncOptions options = options ?? SyncOptions.Default;
+
+ /// Reads who this machine is enrolled as, without needing a passphrase.
+ ///
+ /// Lets the unlock screen greet the user by name and show which server they are enrolled against,
+ /// which is the difference between an unlock prompt and an unexplained password box.
+ ///
+ public Task ReadProfileAsync(CancellationToken cancellationToken) =>
+ new UnlockStore(caches, clock).ReadAsync(cancellationToken);
+
+ /// Attempts to open the vault.
+ public async Task UnlockAsync(string passphrase, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(passphrase);
+
+ var profile = await ReadProfileAsync(cancellationToken).ConfigureAwait(false);
+
+ if (profile is null)
+ {
+ return new UnlockOutcome(
+ UnlockStatus.NotEnrolled,
+ null,
+ "This machine is not enrolled yet. Sign in to a DodoSSH server to set it up.");
+ }
+
+ if (!TryReadKdf(profile, out var kdf))
+ {
+ return new UnlockOutcome(
+ UnlockStatus.UnsupportedKdf,
+ null,
+ $"The stored key derivation settings ('{profile.KdfParameters.Algorithm}') are not "
+ + "supported by this version. Update DodoSSH.");
+ }
+
+ var bundle = OpenBundle(profile, passphrase, kdf, out var protector);
+
+ if (bundle is null)
+ {
+ return new UnlockOutcome(
+ UnlockStatus.WrongPassphrase, null, "That passphrase did not open the vault.");
+ }
+
+ try
+ {
+ return await BuildSessionAsync(profile, bundle, protector!, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch
+ {
+ protector!.Dispose();
+ bundle.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// The master key lives only inside this method. Both things derived from it — the cache subkey and
+ /// the identity bundle — outlive it, which is why they are produced together here rather than by two
+ /// calls that would each need the master key again.
+ ///
+ private static UserSecretBundle? OpenBundle(
+ StoredUnlockMaterial profile,
+ string passphrase,
+ Argon2Profile kdf,
+ out LocalCacheProtector? protector)
+ {
+ protector = null;
+
+ using var master = MasterKey.Derive(passphrase, profile.KdfParameters.Salt, kdf);
+
+ var descriptor = DshAad.UserSecretBundle(profile.UserId, profile.KeyGeneration);
+ var bundle = master.TryOpenBundle(profile.WrappedPrivateKey, descriptor);
+
+ if (bundle is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ protector = LocalCacheProtector.From(master);
+ return bundle;
+ }
+ catch
+ {
+ bundle.Dispose();
+ throw;
+ }
+ }
+
+ private async Task BuildSessionAsync(
+ StoredUnlockMaterial profile,
+ UserSecretBundle bundle,
+ LocalCacheProtector protector,
+ CancellationToken cancellationToken)
+ {
+ var vaults = await new VaultStore(caches, clock)
+ .ListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var keyring = VaultKeyring.Open(bundle, vaults);
+
+ try
+ {
+ var active = vaults.FirstOrDefault(vault => keyring.CanRead(vault.VaultId));
+
+ if (active is null)
+ {
+ keyring.Dispose();
+ protector.Dispose();
+ bundle.Dispose();
+
+ return new UnlockOutcome(
+ UnlockStatus.NoReadableVault,
+ null,
+ vaults.Count == 0
+ ? "No vaults are cached on this machine yet. Sign in to synchronise them."
+ : "Your key does not open any cached vault. It was probably rotated; a member "
+ + "with sharing rights needs to re-issue your access.");
+ }
+
+ var session = new VaultSession(
+ profile, vaults, active.VaultId, bundle, protector, keyring, caches, clock, options);
+
+ return new UnlockOutcome(UnlockStatus.Unlocked, session, $"Unlocked '{active.Name}'.");
+ }
+ catch
+ {
+ keyring.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// The parameters travel with the wrap so that raising them later is a per-user migration at next
+ /// unlock rather than a breaking change. The cost of that is having to handle values this build does
+ /// not recognise, which is what this is: a clear message beats an exception from inside libsodium.
+ ///
+ private static bool TryReadKdf(StoredUnlockMaterial profile, out Argon2Profile kdf)
+ {
+ kdf = Argon2Profile.PassphraseDefault;
+
+ if (!string.Equals(profile.KdfParameters.Algorithm, "argon2id", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ try
+ {
+ kdf = Argon2Profile.FromStoredParameters(
+ profile.KdfParameters.MemoryKibibytes,
+ profile.KdfParameters.Passes,
+ profile.KdfParameters.Parallelism);
+
+ return true;
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return false;
+ }
+ catch (NotSupportedException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Session/VaultSession.cs b/src/DodoSSH.Client.Session/VaultSession.cs
new file mode 100644
index 0000000..6649145
--- /dev/null
+++ b/src/DodoSSH.Client.Session/VaultSession.cs
@@ -0,0 +1,189 @@
+using DodoSSH.Client.Api;
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Sync;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Session;
+
+/// A conflict, decoded and ready to show.
+/// The conflict record, so it can be acknowledged.
+/// The item it happened to.
+/// What happened.
+/// One line for a person.
+///
+/// Everything the merge overrode, with the discarded values. Empty for the kinds that have no field
+/// detail — a rejected push, or an item that would not decrypt.
+///
+/// When it was noticed.
+public sealed record ConflictNotice(
+ Guid Id,
+ Guid EntityId,
+ ConflictKind Kind,
+ string Summary,
+ IReadOnlyList Fields,
+ DateTimeOffset DetectedAt);
+
+///
+/// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable.
+///
+///
+///
+/// Everything a session owns dies with it — the identity bundle, the vault keys and the cache key. That
+/// is the whole reason this is a disposable object rather than a set of long-lived services: locking is
+/// disposing, and there is exactly one place that has to be right.
+///
+///
+/// The sync engine is not held here. It carries no state, so it is constructed per pass around
+/// whichever transport the caller currently has — which models the actual situation, where a session is
+/// perfectly usable with no network at all and syncing is the occasional thing that needs one.
+///
+///
+public sealed class VaultSession : IAsyncDisposable
+{
+ private readonly UserSecretBundle bundle;
+ private readonly LocalCacheProtector protector;
+ private readonly VaultKeyring keyring;
+ private readonly TimeProvider clock;
+ private readonly SyncOptions options;
+ private bool disposed;
+
+ internal VaultSession(
+ StoredUnlockMaterial profile,
+ IReadOnlyList vaults,
+ Guid activeVaultId,
+ UserSecretBundle bundle,
+ LocalCacheProtector protector,
+ VaultKeyring keyring,
+ ClientCacheFactory caches,
+ TimeProvider clock,
+ SyncOptions options)
+ {
+ Profile = profile;
+ Vaults = vaults;
+ ActiveVaultId = activeVaultId;
+
+ this.bundle = bundle;
+ this.protector = protector;
+ this.keyring = keyring;
+ this.clock = clock;
+ this.options = options;
+
+ Items = new ItemStore(caches, protector);
+ Outbox = new OutboxStore(caches, protector, clock);
+ SyncState = new SyncStateStore(caches);
+ Conflicts = new ConflictStore(caches, protector, clock);
+ Vault = new VaultStore(caches, clock);
+ Hosts = new HostRepository(Items, Outbox, keyring);
+ }
+
+ /// Who this session belongs to, and the material that unlocked it.
+ public StoredUnlockMaterial Profile { get; }
+
+ /// Every vault this user can reach, readable or not.
+ public IReadOnlyList Vaults { get; }
+
+ /// The vault the interface is showing. The personal one, for now.
+ public Guid ActiveVaultId { get; }
+
+ /// Hosts, decrypted, with unpushed local changes laid over them.
+ public HostRepository Hosts { get; }
+
+ /// Vaults whose grant could not be opened, so their items cannot be read.
+ public IReadOnlyList UnreadableVaults => keyring.Unopened;
+
+ internal ItemStore Items { get; }
+
+ internal OutboxStore Outbox { get; }
+
+ internal SyncStateStore SyncState { get; }
+
+ internal ConflictStore Conflicts { get; }
+
+ internal VaultStore Vault { get; }
+
+ /// Runs one synchronisation pass over the active vault.
+ /// The transport. Supplied per call because a session outlives any one connection.
+ /// Cancellation token.
+ public Task SyncAsync(ISyncApi api, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ ArgumentNullException.ThrowIfNull(api);
+
+ var engine = new SyncEngine(
+ api, Items, Outbox, SyncState, Conflicts, keyring, clock, options);
+
+ return engine.SyncAsync(ActiveVaultId, cancellationToken);
+ }
+
+ ///
+ /// Reads the conflicts a person still needs to see.
+ ///
+ ///
+ /// A conflict whose detail will not decode is still reported, with the reason in place of the
+ /// summary. The record itself — which item, when, what kind — remains useful even when the
+ /// discarded value has become unreadable, and dropping the row would be the one outcome the whole
+ /// conflict log exists to avoid.
+ ///
+ public async Task> ReadConflictsAsync(
+ CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+
+ var stored = await Conflicts
+ .ListAsync(ActiveVaultId, includeAcknowledged: false, cancellationToken)
+ .ConfigureAwait(false);
+
+ return [.. stored.Select(Describe)];
+ }
+
+ /// Marks a conflict as seen, keeping the discarded value retrievable.
+ public Task AcknowledgeConflictAsync(Guid conflictId, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+
+ return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
+ }
+
+ /// How many local changes are waiting to be pushed.
+ public async Task PendingChangeCountAsync(CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+
+ var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false);
+ return pending.Count;
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ if (disposed)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ disposed = true;
+
+ // Order is not important — none of these depend on another — but completeness is. Missing one
+ // leaves key material in memory for the life of the process, which is the opposite of what
+ // locking is supposed to mean.
+ keyring.Dispose();
+ protector.Dispose();
+ bundle.Dispose();
+
+ return ValueTask.CompletedTask;
+ }
+
+ private static ConflictNotice Describe(StoredConflict conflict)
+ {
+ var detail = ConflictDetails.TryRead(conflict.Detail);
+
+ return new ConflictNotice(
+ conflict.Id,
+ conflict.EntityId,
+ conflict.Kind,
+ detail?.Summary ?? "The details of this conflict could not be read.",
+ detail?.Fields ?? [],
+ conflict.DetectedAt);
+ }
+}
diff --git a/src/DodoSSH.Client.Session/packages.lock.json b/src/DodoSSH.Client.Session/packages.lock.json
new file mode 100644
index 0000000..61ef861
--- /dev/null
+++ b/src/DodoSSH.Client.Session/packages.lock.json
@@ -0,0 +1,267 @@
+{
+ "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.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/src/DodoSSH.Client.Storage/ClientCacheFactory.cs b/src/DodoSSH.Client.Storage/ClientCacheFactory.cs
index 483fe40..7d10984 100644
--- a/src/DodoSSH.Client.Storage/ClientCacheFactory.cs
+++ b/src/DodoSSH.Client.Storage/ClientCacheFactory.cs
@@ -22,6 +22,7 @@ namespace DodoSSH.Client.Storage;
public sealed class ClientCacheFactory : IDbContextFactory, IDisposable
{
private readonly DbContextOptions options;
+ private readonly string connectionString;
///
/// An in-memory SQLite database exists only while at least one connection to it is open, so the
@@ -33,6 +34,7 @@ public sealed class ClientCacheFactory : IDbContextFactory,
private ClientCacheFactory(string connectionString, SqliteConnection? keepAlive)
{
+ this.connectionString = connectionString;
this.keepAlive = keepAlive;
options = new DbContextOptionsBuilder()
@@ -41,7 +43,22 @@ public sealed class ClientCacheFactory : IDbContextFactory,
.Options;
}
- /// Opens, or creates, a cache file.
+ ///
+ /// Opens, or creates, a cache file.
+ ///
+ ///
+ ///
+ /// The parent directory must exist; SQLite will not create one. ClientPaths.EnsureCreated is
+ /// what does that, and it runs before this in the application's startup.
+ ///
+ ///
+ /// The database ends up in WAL mode, set by EF Core's SQLite provider rather than here, and
+ /// that is the right mode for this design: a background sync pass writes while the interface reads,
+ /// and under the default rollback journal a writer locks the whole database and those reads would fail
+ /// busy. Worth knowing rather than discovering — it means the cache is three files, not one, so a
+ /// backup or uninstall routine that copies or deletes only cache.db is wrong.
+ ///
+ ///
/// Full path to the SQLite file.
public static ClientCacheFactory ForFile(string databasePath)
{
@@ -50,8 +67,9 @@ public sealed class ClientCacheFactory : IDbContextFactory,
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.
+ // Pooled, because a store operation takes a context per call and reopening the file every
+ // time would be pure overhead in a process that runs for hours. The cost is that the pool
+ // outlives the contexts, which Dispose has to deal with — see below.
Pooling = true,
};
@@ -115,6 +133,13 @@ public sealed class ClientCacheFactory : IDbContextFactory,
disposed = true;
keepAlive?.Dispose();
+
+ // Pooled connections outlive the contexts that borrowed them, so without this the database file
+ // stays open after the last context is disposed — on Windows that means locked. The application
+ // then cannot delete or replace its own cache, and a test cannot clean up after itself, which is
+ // how this was found. Clearing the pool is the documented way to release it.
+ using var pooled = new SqliteConnection(connectionString);
+ SqliteConnection.ClearPool(pooled);
}
}
diff --git a/src/DodoSSH.Client.Sync/ItemReconciler.cs b/src/DodoSSH.Client.Sync/ItemReconciler.cs
index baaaae1..150207f 100644
--- a/src/DodoSSH.Client.Sync/ItemReconciler.cs
+++ b/src/DodoSSH.Client.Sync/ItemReconciler.cs
@@ -136,7 +136,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
remote.EntityId,
ConflictKind.FieldOverridden,
- ConflictDetailCodec.Encode(
+ ConflictDetails.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);
@@ -195,7 +195,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
remote.EntityId,
ConflictKind.FieldOverridden,
- ConflictDetailCodec.Encode(
+ ConflictDetails.Encode(
$"'{merged.Merged.Label}' was edited in two places at once. "
+ $"{merged.Conflicts.Count} field(s) could not be reconciled automatically.",
merged.Conflicts),
@@ -271,7 +271,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
remote.EntityId,
ConflictKind.RemoteDeleteResurrected,
- ConflictDetailCodec.Encode(
+ ConflictDetails.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);
@@ -294,7 +294,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
remote.EntityId,
ConflictKind.LocalDeleteOverridden,
- ConflictDetailCodec.Encode(
+ ConflictDetails.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);
@@ -372,7 +372,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
remote.EntityId,
ConflictKind.TooNewToEdit,
- ConflictDetailCodec.Encode(
+ ConflictDetails.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."),
@@ -402,7 +402,7 @@ internal sealed class ItemReconciler(
SyncEntityType.Host,
entityId,
ConflictKind.Undecryptable,
- ConflictDetailCodec.Encode(
+ ConflictDetails.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."),
diff --git a/src/DodoSSH.Client.Sync/SyncEngine.cs b/src/DodoSSH.Client.Sync/SyncEngine.cs
index 89b9b1c..b39a84a 100644
--- a/src/DodoSSH.Client.Sync/SyncEngine.cs
+++ b/src/DodoSSH.Client.Sync/SyncEngine.cs
@@ -479,7 +479,7 @@ public sealed class SyncEngine
operation.EntityType,
operation.EntityId,
ConflictKind.Rejected,
- ConflictDetailCodec.Encode(reason),
+ ConflictDetails.Encode(reason),
cancellationToken).ConfigureAwait(false);
report.Parked++;
diff --git a/src/DodoSSH.Client.Sync/SyncReport.cs b/src/DodoSSH.Client.Sync/SyncReport.cs
index 9cd91b5..8380b08 100644
--- a/src/DodoSSH.Client.Sync/SyncReport.cs
+++ b/src/DodoSSH.Client.Sync/SyncReport.cs
@@ -154,14 +154,18 @@ public sealed record ConflictDetailEntry(
public sealed record ConflictDetail(string Summary, IReadOnlyList Fields);
///
-/// Serialises what a merge discarded, for the conflict log.
+/// Reads and writes 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.
+///
+/// Reading is public because the whole justification for resolving a conflict automatically is that the
+/// overridden value gets shown. A codec only the writer could use would make that impossible.
+///
///
-internal static class ConflictDetailCodec
+public static class ConflictDetails
{
internal static byte[] Encode(string summary, IReadOnlyList conflicts) =>
JsonSerializer.SerializeToUtf8Bytes(
@@ -178,7 +182,11 @@ internal static class ConflictDetailCodec
internal static byte[] Encode(string summary) => Encode(summary, []);
/// Reads a detail back, for display.
- internal static ConflictDetail? TryDecode(ReadOnlySpan utf8)
+ ///
+ /// The detail, or when the record will not parse — which is what an entry
+ /// written before a passphrase change looks like, since its sealed bytes no longer open.
+ ///
+ public static ConflictDetail? TryRead(ReadOnlySpan utf8)
{
try
{
diff --git a/tests/DodoSSH.Client.Api.Tests/ClientEnrollmentTests.cs b/tests/DodoSSH.Client.Api.Tests/ClientEnrollmentTests.cs
index 97c6869..d83d97a 100644
--- a/tests/DodoSSH.Client.Api.Tests/ClientEnrollmentTests.cs
+++ b/tests/DodoSSH.Client.Api.Tests/ClientEnrollmentTests.cs
@@ -88,6 +88,7 @@ public sealed class ClientEnrollmentTests : IDisposable
// Nor may any private key appear, in any encoding the serialiser might have chosen.
body.ShouldNotContain(Convert.ToBase64String(outcome.PersonalVaultKey));
+ outcome.DevicePrivateKey.ShouldNotBeNull("this enrollment did bind a device");
body.ShouldNotContain(Convert.ToBase64String(outcome.DevicePrivateKey));
body.ShouldNotContain(Convert.ToHexString(outcome.PersonalVaultKey));
}
@@ -224,7 +225,7 @@ public sealed class ClientEnrollmentTests : IDisposable
var exception = await Should.ThrowAsync(async () =>
await enrollment.EnrollAsync(
- Me(), Passphrase, "laptop", "Personal", TestContext.Current.CancellationToken));
+ Me(), Passphrase, "laptop", "Personal", true, TestContext.Current.CancellationToken));
exception.Code.ShouldBe(ProblemCodes.AlreadyEnrolled);
exception.StatusCode.ShouldBe(HttpStatusCode.Conflict);
@@ -242,7 +243,7 @@ public sealed class ClientEnrollmentTests : IDisposable
await Should.ThrowAsync(async () =>
await enrollment.EnrollAsync(
- Me(), string.Empty, "laptop", "Personal", TestContext.Current.CancellationToken));
+ Me(), string.Empty, "laptop", "Personal", true, TestContext.Current.CancellationToken));
binding.RequestedNonce.ShouldBeNull("Nothing should reach the identity provider.");
}
@@ -265,7 +266,12 @@ public sealed class ClientEnrollmentTests : IDisposable
TimeProvider.System);
return await enrollment.EnrollAsync(
- Me(), Passphrase, "laptop", "Personal", TestContext.Current.CancellationToken);
+ Me(),
+ Passphrase,
+ "laptop",
+ "Personal",
+ bindThisDevice: true,
+ TestContext.Current.CancellationToken);
}
private EnrollmentRequest ReadRequest()
diff --git a/tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj b/tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj
new file mode 100644
index 0000000..a4f7165
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs
new file mode 100644
index 0000000..395289f
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs
@@ -0,0 +1,200 @@
+using DodoSSH.Client.Api;
+using DodoSSH.Client.Auth;
+using DodoSSH.Client.Session;
+using DodoSSH.Client.Sync;
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Client.App.Tests;
+
+///
+/// A signed-in server, without the signing in.
+///
+///
+/// Stands in for a ServerConnection so the shell's state machine can be driven end to end. The
+/// account half stores what it is given and reports it back, because the provisioner re-reads /me
+/// after enrolling and a stub that echoed the request would make that check meaningless. The sync half
+/// applies pushes and serves them back as a change log, which is enough for the shell — the interesting
+/// conflict behaviour is covered in DodoSSH.Client.Sync.Tests against a server that enforces
+/// version checks.
+///
+internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
+{
+ private readonly List log = [];
+ private readonly Dictionary rows = [];
+
+ private KeyStatement? statement;
+ private byte[]? wrappedPrivateKey;
+ private KdfParameters? kdfParameters;
+ private VaultSummary? personalVault;
+
+ internal Guid UserId { get; } = Guid.Parse("0192f0c8-4444-7aaa-8bbb-dddddddddddd");
+
+ internal int EnrollmentCount { get; private set; }
+
+ internal int PushCount { get; private set; }
+
+ internal bool IsEnrolled => statement is not null;
+
+ internal int LiveRowCount => rows.Values.Count(row => row.Operation != SyncOperation.Delete);
+
+ /// When set, the next sign-in throws — how an unreachable server is exercised.
+ internal Exception? SignInFailure { get; set; }
+
+ ///
+ public Uri ServerUrl { get; } = new("https://dodossh.example");
+
+ ///
+ public IAccountApi Account => this;
+
+ ///
+ public ISyncApi Sync => this;
+
+ ///
+ public IKeyBindingAuthorizer KeyBinding => this;
+
+ ///
+ public SyncOptions SyncOptions => SyncOptions.Default;
+
+ ///
+ public void Dispose()
+ {
+ // Nothing to release; the shell disposes this on lock and on shutdown, and both paths have to be
+ // safe to run more than once.
+ }
+
+ // ---- Identity provider ----
+
+ ///
+ public Task AuthorizeKeyBindingAsync(string bindingNonce, CancellationToken cancellationToken) =>
+ Task.FromResult("stub-id-token");
+
+ // ---- Account ----
+
+ ///
+ public Task GetMeAsync(CancellationToken cancellationToken) =>
+ Task.FromResult(new MeResponse(
+ UserId,
+ "https://idp.example/realms/dodossh",
+ "alice",
+ "alice@example.com",
+ "Alice Example",
+ EnrollmentRequired: !IsEnrolled,
+ KeyGeneration: statement?.KeyGeneration,
+ WrappedPrivateKey: wrappedPrivateKey,
+ KdfParameters: kdfParameters,
+ Vaults: personalVault is null ? [] : [personalVault]));
+
+ ///
+ public Task EnrollAsync(
+ EnrollmentRequest request,
+ CancellationToken cancellationToken)
+ {
+ EnrollmentCount++;
+
+ statement = request.Statement;
+ wrappedPrivateKey = request.WrappedPrivateKey;
+ kdfParameters = request.KdfParameters;
+
+ personalVault = new VaultSummary(
+ request.PersonalVault.VaultId,
+ request.PersonalVault.Name,
+ IsPersonal: true,
+ TeamId: null,
+ KeyGeneration: 1,
+ Permissions: 31,
+ request.PersonalVault.WrappedVaultKey,
+ RekeyRequired: false);
+
+ return Task.FromResult(new EnrollmentResponse(
+ UserId,
+ KeyGeneration: 1,
+ Fingerprint: new byte[32],
+ request.PersonalVault.VaultId,
+ DeviceId: null,
+ KeyLogSequence: 1));
+ }
+
+ // ---- Sync ----
+
+ ///
+ public Task SyncPullAsync(
+ Guid vaultId,
+ SyncPullRequest request,
+ CancellationToken cancellationToken)
+ {
+ var after = request.Cursor is null
+ ? 0
+ : long.Parse(request.Cursor.AsSpan("app-v1:".Length), provider: null);
+
+ var page = log.Where(change => change.ChangeSequence > after).ToList();
+ var next = page.Count > 0 ? page[^1].ChangeSequence : after;
+
+ return Task.FromResult(new SyncPullResponse(
+ page,
+ $"app-v1:{next}",
+ HasMore: false,
+ ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000),
+ CurrentKeyGeneration: 1));
+ }
+
+ ///
+ public Task SyncPushAsync(
+ Guid vaultId,
+ SyncPushRequest request,
+ CancellationToken cancellationToken)
+ {
+ PushCount++;
+
+ var results = new List(request.Operations.Count);
+
+ foreach (var operation in request.Operations)
+ {
+ results.Add(Apply(operation));
+ }
+
+ return Task.FromResult(new SyncPushResponse(results, $"app-v1:{log.Count}"));
+ }
+
+ private SyncPushResult Apply(SyncPushOperation operation)
+ {
+ rows.TryGetValue(operation.EntityId, out var existing);
+
+ var current = existing?.Operation == SyncOperation.Delete ? null : existing;
+
+ if (operation.ExpectedVersion != current?.Version)
+ {
+ return new SyncPushResult(
+ operation.OperationId,
+ SyncOperationStatus.Conflict,
+ current?.Version,
+ current?.ChangeSequence,
+ current,
+ null);
+ }
+
+ var sequence = log.Count + 1;
+
+ var change = new SyncChange(
+ operation.EntityType,
+ operation.EntityId,
+ operation.Operation,
+ Version: (current?.Version ?? 0) + 1,
+ ChangeSequence: sequence,
+ Payload: operation.Operation == SyncOperation.Delete ? null : operation.Payload,
+ PlaintextFields: operation.Operation == SyncOperation.Delete
+ ? null
+ : operation.PlaintextFields,
+ UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000 + sequence));
+
+ rows[operation.EntityId] = change;
+ log.Add(change);
+
+ return new SyncPushResult(
+ operation.OperationId,
+ SyncOperationStatus.Applied,
+ change.Version,
+ sequence,
+ null,
+ null);
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
new file mode 100644
index 0000000..4758428
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -0,0 +1,440 @@
+using DodoSSH.Client.App.ViewModels;
+using DodoSSH.Client.Session;
+using DodoSSH.Client.Ssh;
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Terminal;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.App.Tests;
+
+///
+/// The whole path a user walks: sign in, enroll, keep the recovery code, unlock, add a host, sync.
+///
+///
+/// Runs with no Avalonia, no browser and no identity provider, because the view models are plain
+/// observable objects and sign-in is a delegate. What that buys is that the states most likely to be got
+/// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test
+/// rather than by remembering to click through them.
+///
+public sealed class ShellFlowTests : IAsyncLifetime
+{
+ private const string Passphrase = "a sufficiently long passphrase";
+
+ ///
+ /// Far below the shipped profile, for the same reason as everywhere else: these tests are about the
+ /// state machine, not about how expensive the passphrase is to attack.
+ ///
+ private static readonly Argon2Profile CheapProfile =
+ Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
+
+ private readonly FakeVaultServer server = new();
+
+ private string directory = null!;
+ private ClientPaths paths = null!;
+ private ClientCacheFactory caches = null!;
+ private TerminalWorkspace workspace = null!;
+ private MainWindowViewModel shell = null!;
+
+ ///
+ public ValueTask InitializeAsync()
+ {
+ // A real directory and a real SQLite file, because the production path is what StartAsync runs and
+ // an in-memory database would skip the migration that creates the file.
+ directory = Path.Combine(Path.GetTempPath(), $"dodossh-shell-{Guid.CreateVersion7():N}");
+ paths = new ClientPaths(directory);
+ caches = ClientCacheFactory.ForFile(paths.CacheFile);
+
+ var knownHosts = new InMemoryKnownHostStore();
+
+ // In-memory assets rather than the application's Avalonia-resource provider, which reads the
+ // resource system at construction and needs an initialised toolkit. This is what
+ // ITerminalAssetProvider is for; nothing in this suite renders anything.
+ workspace = new TerminalWorkspace(
+ new InMemoryTerminalAssetProvider(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["/terminal"] = new("text/html; charset=utf-8", ""u8.ToArray()),
+ }),
+ new SshNetConnectionFactory(knownHosts),
+ TimeProvider.System);
+
+ shell = new MainWindowViewModel(
+ paths,
+ caches,
+ workspace,
+ knownHosts,
+ SignInAsync,
+ TimeProvider.System,
+ CheapProfile);
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await shell.DisposeAsync();
+ await workspace.DisposeAsync();
+ caches.Dispose();
+
+ if (Directory.Exists(directory))
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task AFreshMachine_AsksForAServer()
+ {
+ await shell.StartAsync(Token);
+
+ shell.State.ShouldBe(ShellState.NeedsServer);
+ shell.IsNeedingServer.ShouldBeTrue();
+ shell.IsOnline.ShouldBeFalse();
+
+ // The migration ran, so the file exists before anyone has signed in to anything.
+ File.Exists(paths.CacheFile).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task SigningInToAnUnenrolledAccount_AsksForAPassphrase()
+ {
+ await shell.StartAsync(Token);
+ await shell.SignInCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.NeedsEnrollment);
+ shell.IsOnline.ShouldBeTrue();
+ shell.AccountName.ShouldBe("Alice Example");
+ }
+
+ [Fact]
+ public async Task AnUnreachableServer_ReportsAndStaysPut()
+ {
+ server.SignInFailure = new HttpRequestException("No such host is known.");
+
+ await shell.StartAsync(Token);
+ await shell.SignInCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.NeedsServer);
+ shell.StatusMessage.ShouldContain("No such host");
+ shell.IsBusy.ShouldBeFalse("a failed command must not leave the window disabled");
+ }
+
+ [Fact]
+ public async Task AnInvalidServerUrl_IsRejectedWithoutTouchingTheNetwork()
+ {
+ await shell.StartAsync(Token);
+
+ shell.ServerUrl = "not a url";
+ await shell.SignInCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.NeedsServer);
+ shell.IsOnline.ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData("short", "short")]
+ [InlineData("a sufficiently long passphrase", "a different one")]
+ public async Task AWeakOrMismatchedPassphrase_DoesNotEnroll(string entered, string confirmation)
+ {
+ await SignedInAsync();
+
+ shell.Passphrase = entered;
+ shell.ConfirmPassphrase = confirmation;
+
+ await shell.EnrollCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.NeedsEnrollment);
+ server.EnrollmentCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task TheRecoveryCodeScreen_CannotBeSkipped()
+ {
+ // The only moment the code exists. Losing it along with the passphrase means the vault is
+ // unrecoverable and there is no server-side reset, so this is the one screen that has to insist.
+ await EnrolledAsync();
+
+ shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
+ shell.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
+
+ // Trying to continue without confirming gets nowhere.
+ shell.ConfirmRecoveryCodeCommand.Execute(null);
+ shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
+ shell.RecoveryCode.ShouldNotBeNull();
+
+ shell.RecoveryCodeWrittenDown = true;
+ shell.ConfirmRecoveryCodeCommand.Execute(null);
+
+ shell.State.ShouldBe(ShellState.Locked);
+
+ // And it is dropped from memory, not merely hidden. It was never persisted; keeping it in a view
+ // model for the rest of the session would undo that.
+ shell.RecoveryCode.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task AWrongPassphrase_KeepsTheVaultLocked()
+ {
+ await ReadyToUnlockAsync();
+
+ shell.Passphrase = "not the passphrase";
+ await shell.UnlockCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.Locked);
+ shell.Vault.ShouldBeNull();
+ shell.StatusMessage.ShouldContain("did not open");
+ }
+
+ [Fact]
+ public async Task UnlockingOpensTheVault()
+ {
+ await UnlockedAsync();
+
+ shell.State.ShouldBe(ShellState.Unlocked);
+ shell.Vault.ShouldNotBeNull();
+ shell.Vault.VaultName.ShouldBe("Personal");
+
+ // Cleared once used, so it is not sitting in a bound property for the rest of the session.
+ shell.Passphrase.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task ARestartUnlocksWithNoNetworkAtAll()
+ {
+ // The property the whole storage layer exists for, from the shell's point of view. The second
+ // shell is given a sign-in delegate that fails if called.
+ await EnrolledAndConfirmedAsync();
+ await shell.LockCommand.ExecuteAsync(null);
+
+ var offline = new MainWindowViewModel(
+ paths,
+ caches,
+ workspace,
+ new InMemoryKnownHostStore(),
+ (_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
+ TimeProvider.System,
+ CheapProfile);
+
+ await using var _ = offline.ConfigureAwait(false);
+
+ await offline.StartAsync(Token);
+
+ offline.State.ShouldBe(ShellState.Locked);
+ offline.AccountName.ShouldBe("Alice Example");
+ offline.IsOnline.ShouldBeFalse();
+
+ offline.Passphrase = Passphrase;
+ await offline.UnlockCommand.ExecuteAsync(null);
+
+ offline.State.ShouldBe(ShellState.Unlocked);
+ offline.Vault.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task AddingAHost_ShowsItImmediatelyAndQueuesIt()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ vault.NewHostCommand.Execute(null);
+ vault.IsEditing.ShouldBeTrue();
+
+ vault.EditorLabel = "prod-db";
+ vault.EditorHostname = "db.internal";
+ vault.EditorUsername = "deploy";
+ vault.EditorPort = 2222;
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ vault.IsEditing.ShouldBeFalse();
+
+ var row = vault.Hosts.ShouldHaveSingleItem();
+ row.Label.ShouldBe("prod-db");
+ row.Address.ShouldBe("deploy@db.internal:2222");
+ row.HasUnsyncedChanges.ShouldBeTrue();
+ row.Badge.ShouldBe("not synced");
+
+ vault.PendingChanges.ShouldBe(1);
+ server.LiveRowCount.ShouldBe(0, "nothing should have been pushed yet");
+ }
+
+ [Fact]
+ public async Task AnInvalidHost_IsRefusedWithAReason()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ vault.NewHostCommand.Execute(null);
+ vault.EditorLabel = " ";
+ vault.EditorHostname = "db.internal";
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ vault.IsEditing.ShouldBeTrue("the editor should stay open so the user can fix it");
+ vault.Hosts.ShouldBeEmpty();
+ vault.Status.ShouldContain("needs a name");
+ }
+
+ [Fact]
+ public async Task SyncingSendsTheQueueAndClearsIt()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ await vault.SyncCommand.ExecuteAsync(null);
+
+ server.LiveRowCount.ShouldBe(1);
+ vault.PendingChanges.ShouldBe(0);
+ vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeFalse();
+ vault.Status.ShouldContain("Synchronised");
+ }
+
+ [Fact]
+ public async Task EditingAHostRoundTripsThroughTheEditor()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ await vault.SyncCommand.ExecuteAsync(null);
+
+ vault.SelectedHost = vault.Hosts[0];
+ vault.EditSelectedHostCommand.Execute(null);
+
+ vault.EditorLabel.ShouldBe("prod-db");
+ vault.EditorHostname.ShouldBe("db.internal");
+
+ vault.EditorNotes = "rotate quarterly";
+ await vault.SaveHostCommand.ExecuteAsync(null);
+ await vault.SyncCommand.ExecuteAsync(null);
+
+ vault.Hosts.ShouldHaveSingleItem().Host.Notes.ShouldBe("rotate quarterly");
+ }
+
+ [Fact]
+ public async Task DeletingAHostRemovesItLocallyBeforeTheServerAgrees()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ await vault.SyncCommand.ExecuteAsync(null);
+
+ vault.SelectedHost = vault.Hosts[0];
+ await vault.DeleteHostCommand.ExecuteAsync(null);
+
+ vault.Hosts.ShouldBeEmpty();
+ server.LiveRowCount.ShouldBe(1, "the tombstone has not been pushed yet");
+
+ await vault.SyncCommand.ExecuteAsync(null);
+ server.LiveRowCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task SyncingWhileOffline_QueuesRatherThanFailing()
+ {
+ await EnrolledAndConfirmedAsync();
+ await shell.LockCommand.ExecuteAsync(null);
+
+ // Locking does not drop the connection, so take a fresh shell that never signed in.
+ var offline = new MainWindowViewModel(
+ paths,
+ caches,
+ workspace,
+ new InMemoryKnownHostStore(),
+ (_, _) => throw new InvalidOperationException("unreachable"),
+ TimeProvider.System,
+ CheapProfile);
+
+ await using var _ = offline.ConfigureAwait(false);
+
+ await offline.StartAsync(Token);
+ offline.Passphrase = Passphrase;
+ await offline.UnlockCommand.ExecuteAsync(null);
+
+ var vault = offline.Vault!;
+ await AddHostAsync(vault, "offline-host");
+
+ await vault.SyncCommand.ExecuteAsync(null);
+
+ vault.Status.ShouldContain("Offline");
+ vault.PendingChanges.ShouldBe(1, "the change is kept, not discarded");
+ server.PushCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task LockingForgetsTheVault()
+ {
+ await UnlockedAsync();
+
+ await shell.LockCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.Locked);
+ shell.Vault.ShouldBeNull();
+
+ // And unlocking again works, so locking released rather than corrupted anything.
+ shell.Passphrase = Passphrase;
+ await shell.UnlockCommand.ExecuteAsync(null);
+ shell.State.ShouldBe(ShellState.Unlocked);
+ }
+
+ // ---- Helpers ----
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private Task SignInAsync(Uri serverUrl, CancellationToken cancellationToken) =>
+ server.SignInFailure is { } failure
+ ? Task.FromException(failure)
+ : Task.FromResult(server);
+
+ private async Task SignedInAsync()
+ {
+ await shell.StartAsync(Token);
+ await shell.SignInCommand.ExecuteAsync(null);
+ shell.State.ShouldBe(ShellState.NeedsEnrollment);
+ }
+
+ private async Task EnrolledAsync()
+ {
+ await SignedInAsync();
+
+ shell.Passphrase = Passphrase;
+ shell.ConfirmPassphrase = Passphrase;
+
+ await shell.EnrollCommand.ExecuteAsync(null);
+ }
+
+ private async Task EnrolledAndConfirmedAsync()
+ {
+ await EnrolledAsync();
+
+ shell.RecoveryCodeWrittenDown = true;
+ shell.ConfirmRecoveryCodeCommand.Execute(null);
+
+ shell.State.ShouldBe(ShellState.Locked);
+ }
+
+ private Task ReadyToUnlockAsync() => EnrolledAndConfirmedAsync();
+
+ private async Task UnlockedAsync()
+ {
+ await EnrolledAndConfirmedAsync();
+
+ shell.Passphrase = Passphrase;
+ await shell.UnlockCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
+ }
+
+ private static async Task AddHostAsync(VaultViewModel vault, string label)
+ {
+ vault.NewHostCommand.Execute(null);
+ vault.EditorLabel = label;
+ vault.EditorHostname = "db.internal";
+ vault.EditorUsername = "deploy";
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Tests/packages.lock.json b/tests/DodoSSH.Client.App.Tests/packages.lock.json
new file mode 100644
index 0000000..585e7b5
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/packages.lock.json
@@ -0,0 +1,710 @@
+{
+ "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]"
+ }
+ },
+ "Avalonia.Angle.Windows.Natives": {
+ "type": "Transitive",
+ "resolved": "2.1.27548.20260419",
+ "contentHash": "l17nI3XVDN3oMnpjf2pnmJg0YTwK4m6NLsn/itAjDMdObTFxN77D5F1M9sRMSfViSY3KKcse1ROczwgoWLJsnA=="
+ },
+ "Avalonia.BuildServices": {
+ "type": "Transitive",
+ "resolved": "11.3.2",
+ "contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
+ },
+ "Avalonia.FreeDesktop": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "89mrS7dSYtisJrjQufCOomHeAynlVVKZ+dq4leKtzHXXKVoWsE0Nb2ymiNYPMlirwzwpemflGj+K34opwyLeJQ==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "Tmds.DBus.Protocol": "0.94.1"
+ }
+ },
+ "Avalonia.FreeDesktop.AtSpi": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "WWahMjzKtDl2PGHa8mS6NHIVMT+JNKVIeT5xMLp9SBTMvjHNpEbXTX3PNbbIQ7hRMSETAJ/PAnvgOzatGktEKQ==",
+ "dependencies": {
+ "Avalonia": "12.1.0"
+ }
+ },
+ "Avalonia.HarfBuzz": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "uWPa/kg+fmqhrUR5GzFC2ZL2MPszxcEy14hTSwu3VhjwTnaVarUozoPmggQjZG4A4s6w2bcGepsYYaCM/eOoMA==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "HarfBuzzSharp": "8.3.1.3",
+ "HarfBuzzSharp.NativeAssets.Linux": "8.3.1.3",
+ "HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.3"
+ }
+ },
+ "Avalonia.Native": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "mfNMtGP7rEVWSZoii0l40mlNhgNR6ZISTvHP7OAMn5YQHiK66EjfN26SL/kLnz1buy2VtMIiGVBbIttL8FYCZg==",
+ "dependencies": {
+ "Avalonia": "12.1.0"
+ }
+ },
+ "Avalonia.Remote.Protocol": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "p6OKt6O7vOub4TS2pAjaeW0Y13oxrPs4uixeVZpJByiSQKKk+LyApN5yRy2JerpfTMtI86Y5pNwugyKTHZJnAw=="
+ },
+ "Avalonia.Skia": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "K63pwExQkcjVbsYJOiOq0hYAO4G5d7T42yK8MGNrvwBKv/bJVlV14jGvV4wXcsuYAU8IWlOHgqq5sMUiDfj4vw==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "HarfBuzzSharp": "8.3.1.3",
+ "HarfBuzzSharp.NativeAssets.Linux": "8.3.1.3",
+ "HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.3",
+ "SkiaSharp": "3.119.4",
+ "SkiaSharp.NativeAssets.Linux": "3.119.4",
+ "SkiaSharp.NativeAssets.WebAssembly": "3.119.4"
+ }
+ },
+ "Avalonia.Win32": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "D0xxPtFeOK8cKK991ul92rlFtDO3II0E44dHM0ix4/8LVc9+1LaMdbf1eMnp1RclFwX2bt7HPhbySnrG5uArxA==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "Avalonia.Angle.Windows.Natives": "2.1.27548.20260419"
+ }
+ },
+ "Avalonia.X11": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "6+YHVGf44ictmGj88diMCw9pC9tiwnMlUgosDi0VDmyUQFuy/mJIa4J0rZu6G+UMbjGuyLDQLmtvU4tTOhLMPg==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "Avalonia.FreeDesktop": "12.1.0",
+ "Avalonia.FreeDesktop.AtSpi": "12.1.0",
+ "Avalonia.Skia": "12.1.0"
+ }
+ },
+ "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=="
+ },
+ "HarfBuzzSharp": {
+ "type": "Transitive",
+ "resolved": "8.3.1.3",
+ "contentHash": "NGZ2+ZVNPM+NdHB/asW0/ykWngyHWwcqjrbN2nDeH1B/aptPGlCUl8wkQ2cSJxw5fdWgdmIPmNuTPWpLwNVXWg==",
+ "dependencies": {
+ "HarfBuzzSharp.NativeAssets.Win32": "8.3.1.3",
+ "HarfBuzzSharp.NativeAssets.macOS": "8.3.1.3"
+ }
+ },
+ "HarfBuzzSharp.NativeAssets.Linux": {
+ "type": "Transitive",
+ "resolved": "8.3.1.3",
+ "contentHash": "RI6A1LgmooU30+4QIyFt5rmBCzP0VzTR+587IJSGvYIsHHWlahFufihYxtraLfsIhW7I8dn6+xX+DZGygOPKWQ=="
+ },
+ "HarfBuzzSharp.NativeAssets.macOS": {
+ "type": "Transitive",
+ "resolved": "8.3.1.3",
+ "contentHash": "KPTq0xnslkI6nAo0jh3ptcQPJvZZr7MWYXa2jUe4SnHc9q+JlHElmNXp0sfFoiTgoCX7WOYpYsurypuH9Gehxw=="
+ },
+ "HarfBuzzSharp.NativeAssets.WebAssembly": {
+ "type": "Transitive",
+ "resolved": "8.3.1.3",
+ "contentHash": "w2QfdNm9Uz/sUa0B5D+OnVQhyq3G/fBq6ibQMdWBlQqqwh0g0/5j3RFvYqZAmRZ5+RzvjVe8o8SFFnWYUSkuxA=="
+ },
+ "HarfBuzzSharp.NativeAssets.Win32": {
+ "type": "Transitive",
+ "resolved": "8.3.1.3",
+ "contentHash": "bx8CE8Js+XGX8PUxAHCBDEORt5aaBYtMN4Hr9QFs57Xithh6yjUyYqksizH6eRDhJkwsGI+SXWmPmMm8lZC9Pw=="
+ },
+ "MicroCom.Runtime": {
+ "type": "Transitive",
+ "resolved": "0.11.6",
+ "contentHash": "NdNWGDiZ6eS/Mf/9+QHR91cj1K7Hy+PX9yrHI/zM7xFYuj9IWT2uxtB6sCHjrnxAeLV9fut1R6zHDUGKX6f9lQ=="
+ },
+ "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=="
+ },
+ "SkiaSharp": {
+ "type": "Transitive",
+ "resolved": "3.119.4",
+ "contentHash": "53NOSUZ1Us+91Sm0uCkIivh/k7jOowRErZT2sIWwPFN9mLUvdxnE6rS4sWo4255+Rd2MWUSF+j0NMZHD6Cke+Q==",
+ "dependencies": {
+ "SkiaSharp.NativeAssets.Win32": "3.119.4",
+ "SkiaSharp.NativeAssets.macOS": "3.119.4"
+ }
+ },
+ "SkiaSharp.NativeAssets.Linux": {
+ "type": "Transitive",
+ "resolved": "3.119.4",
+ "contentHash": "UAyVzbqNfZsZbKbzj68zXLyUyF/SbTKmzTfOO6qDu++dtIUMMTzPBe8oOuzU/DiewpfKoUUlOSsJmqWc6blxBw=="
+ },
+ "SkiaSharp.NativeAssets.macOS": {
+ "type": "Transitive",
+ "resolved": "3.119.4",
+ "contentHash": "fgBOWEqbY012x7gMfJU4ezgz6dfhJb30Z6YdW35h85Zoe39+a8YNbAAwL29ihPfWoppg5AjvyKNzD1oCvlqWwA=="
+ },
+ "SkiaSharp.NativeAssets.WebAssembly": {
+ "type": "Transitive",
+ "resolved": "3.119.4",
+ "contentHash": "S1HOxtBbD4bYDtA2e9WH5TX+lxqRrTPvKjrjttRhxnHNNu7YY8VFo/LeCP7tNqoTA6PV+8vsvNbmRUEC2ip8RQ=="
+ },
+ "SkiaSharp.NativeAssets.Win32": {
+ "type": "Transitive",
+ "resolved": "3.119.4",
+ "contentHash": "XOpbx/4CReO2wYsq2s6rbvdauc6dntG4Zv499sHGTJ87bwZaFXszFkwql3+FIZMc8kUPeaj3Mx2ezIJmo8a1Kg=="
+ },
+ "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"
+ }
+ },
+ "Tmds.DBus.Protocol": {
+ "type": "Transitive",
+ "resolved": "0.94.1",
+ "contentHash": "11YMr7FnAbL83bQmVxlhbIKHvSLxjO81D12Ej0QMSGXMDTxNA9MTOa4MQxx43nv5el/efuPHwzyrj6a5ha2gug=="
+ },
+ "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.app": {
+ "type": "Project",
+ "dependencies": {
+ "Avalonia": "[12.1.0, )",
+ "Avalonia.Controls.WebView": "[12.0.1, )",
+ "Avalonia.Desktop": "[12.1.0, )",
+ "Avalonia.Fonts.Inter": "[12.1.0, )",
+ "Avalonia.Themes.Fluent": "[12.1.0, )",
+ "CommunityToolkit.Mvvm": "[8.4.2, )",
+ "DodoSSH.Client.Session": "[1.0.0, )",
+ "DodoSSH.Client.Ssh": "[1.0.0, )",
+ "DodoSSH.Client.Terminal": "[1.0.0, )"
+ }
+ },
+ "dodossh.client.auth": {
+ "type": "Project"
+ },
+ "dodossh.client.domain": {
+ "type": "Project"
+ },
+ "dodossh.client.session": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Api": "[1.0.0, )",
+ "DodoSSH.Client.Auth": "[1.0.0, )",
+ "DodoSSH.Client.Domain": "[1.0.0, )",
+ "DodoSSH.Client.Storage": "[1.0.0, )",
+ "DodoSSH.Client.Sync": "[1.0.0, )"
+ }
+ },
+ "dodossh.client.ssh": {
+ "type": "Project",
+ "dependencies": {
+ "SSH.NET": "[2025.1.0, )"
+ }
+ },
+ "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.client.terminal": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Ssh": "[1.0.0, )"
+ }
+ },
+ "dodossh.contracts": {
+ "type": "Project"
+ },
+ "dodossh.crypto": {
+ "type": "Project",
+ "dependencies": {
+ "NSec.Cryptography": "[26.4.0, )"
+ }
+ },
+ "Avalonia": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.0, )",
+ "resolved": "12.1.0",
+ "contentHash": "an4ugAy2q6GTdaFl635V8W/LKrWNL+mnSFUprbgyf8m1Zzf9WgoGaF5ajGv5i4gXefMZrzsUPV1QU+kKrgwCWg==",
+ "dependencies": {
+ "Avalonia.BuildServices": "11.3.2",
+ "Avalonia.Remote.Protocol": "12.1.0",
+ "MicroCom.Runtime": "0.11.6"
+ }
+ },
+ "Avalonia.Controls.WebView": {
+ "type": "CentralTransitive",
+ "requested": "[12.0.1, )",
+ "resolved": "12.0.1",
+ "contentHash": "GrCIpIIBL7ueFDsNu3lyYc1mgO3QGGl1c1MCK8YAgjaNZwF9PV5PF2UB3lm1uuqj/MWOKNhemLwcSDLyYv0JjQ==",
+ "dependencies": {
+ "Avalonia": "12.0.0"
+ }
+ },
+ "Avalonia.Desktop": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.0, )",
+ "resolved": "12.1.0",
+ "contentHash": "mxhz50At61IBQbB/bCo5JGp53rPi3GerGO9mFo/v93uBHa4J3cz3NSSqnVWSyQXKbPCwQhrogfEFWbKBgecy1w==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "Avalonia.HarfBuzz": "12.1.0",
+ "Avalonia.Native": "12.1.0",
+ "Avalonia.Skia": "12.1.0",
+ "Avalonia.Win32": "12.1.0",
+ "Avalonia.X11": "12.1.0"
+ }
+ },
+ "Avalonia.Fonts.Inter": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.0, )",
+ "resolved": "12.1.0",
+ "contentHash": "2mK5Rv6aMWgXfQ2JZOq1Wo2bTNAfiidg2GO4b3MgLRN89ezfvfsSfp4P5Pl4ssRcWWwdV0jGzIw8n0xc9B26VA==",
+ "dependencies": {
+ "Avalonia": "12.1.0"
+ }
+ },
+ "Avalonia.Themes.Fluent": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.0, )",
+ "resolved": "12.1.0",
+ "contentHash": "MVi5L9HymnNm+gP2aNXNcyrP2iKGJWFubQ5Bv8/Przflxca1aIT9QBpTUV6c3olA9rLYFY7MJRR/C/BZaUhemQ==",
+ "dependencies": {
+ "Avalonia": "12.1.0"
+ }
+ },
+ "BouncyCastle.Cryptography": {
+ "type": "CentralTransitive",
+ "requested": "[2.6.2, )",
+ "resolved": "2.6.2",
+ "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
+ },
+ "CommunityToolkit.Mvvm": {
+ "type": "CentralTransitive",
+ "requested": "[8.4.2, )",
+ "resolved": "8.4.2",
+ "contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
+ },
+ "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"
+ }
+ },
+ "SSH.NET": {
+ "type": "CentralTransitive",
+ "requested": "[2025.1.0, )",
+ "resolved": "2025.1.0",
+ "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
+ "dependencies": {
+ "BouncyCastle.Cryptography": "2.6.2",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/DodoSSH.Client.Session.Tests/ClientPathsTests.cs b/tests/DodoSSH.Client.Session.Tests/ClientPathsTests.cs
new file mode 100644
index 0000000..a7f3a6a
--- /dev/null
+++ b/tests/DodoSSH.Client.Session.Tests/ClientPathsTests.cs
@@ -0,0 +1,81 @@
+namespace DodoSSH.Client.Session.Tests;
+
+///
+/// Where the profile goes.
+///
+///
+/// A short suite for something that looks trivial and is not. The cache is a SQLite file written by one
+/// process, and the design assumes each machine has its own — the outbox holds changes only this machine
+/// has made. Two machines sharing one file through a cloud sync client corrupts it, so a roaming or
+/// synced directory is a correctness problem rather than a matter of taste.
+///
+public sealed class ClientPathsTests
+{
+ [Fact]
+ public void TheCacheLivesInsideTheProfileDirectory()
+ {
+ var paths = new ClientPaths(Path.Combine("C:", "somewhere", "DodoSSH"));
+
+ Path.GetDirectoryName(paths.CacheFile).ShouldBe(paths.DataDirectory);
+ Path.GetFileName(paths.CacheFile).ShouldBe("cache.db");
+ }
+
+ [Fact]
+ public void TheDefaultDirectoryIsAbsoluteAndNamed()
+ {
+ var paths = ClientPaths.Default;
+
+ Path.IsPathFullyQualified(paths.DataDirectory).ShouldBeTrue(paths.DataDirectory);
+
+ paths.DataDirectory.Contains("odoSSH", StringComparison.Ordinal)
+ .ShouldBeTrue($"'{paths.DataDirectory}' should be identifiable as ours");
+ }
+
+ [Fact]
+ public void ResolvingTheDefault_CreatesNothing()
+ {
+ // Read during startup diagnostics and by tests. A side effect here would mean merely asking where
+ // the cache would go creates a directory.
+ var paths = new ClientPaths(
+ Path.Combine(Path.GetTempPath(), $"dodossh-paths-{Guid.CreateVersion7():N}"));
+
+ Directory.Exists(paths.DataDirectory).ShouldBeFalse();
+
+ paths.EnsureCreated();
+
+ try
+ {
+ Directory.Exists(paths.DataDirectory).ShouldBeTrue();
+
+ // Idempotent, because startup runs it every launch.
+ paths.EnsureCreated();
+ }
+ finally
+ {
+ Directory.Delete(paths.DataDirectory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void OnWindowsItIsTheLocalProfileAndNotTheRoamingOne()
+ {
+ // %APPDATA% roams in a domain environment, which would sync one machine's SQLite cache to another
+ // and corrupt it. %LOCALAPPDATA% does not.
+ if (!OperatingSystem.IsWindows())
+ {
+ Assert.Skip("Windows-only path convention.");
+ }
+
+ var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+
+ ClientPaths.Default.DataDirectory.ShouldStartWith(local);
+
+ // Guard against the two happening to be equal on some configuration, which would make the
+ // assertion above meaningless.
+ if (!string.Equals(local, roaming, StringComparison.OrdinalIgnoreCase))
+ {
+ ClientPaths.Default.DataDirectory.ShouldNotStartWith(roaming);
+ }
+ }
+}
diff --git a/tests/DodoSSH.Client.Session.Tests/DodoSSH.Client.Session.Tests.csproj b/tests/DodoSSH.Client.Session.Tests/DodoSSH.Client.Session.Tests.csproj
new file mode 100644
index 0000000..d0e0c9b
--- /dev/null
+++ b/tests/DodoSSH.Client.Session.Tests/DodoSSH.Client.Session.Tests.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/DodoSSH.Client.Session.Tests/FakeAccountServer.cs b/tests/DodoSSH.Client.Session.Tests/FakeAccountServer.cs
new file mode 100644
index 0000000..dae112c
--- /dev/null
+++ b/tests/DodoSSH.Client.Session.Tests/FakeAccountServer.cs
@@ -0,0 +1,170 @@
+using DodoSSH.Client.Api;
+using DodoSSH.Client.Auth;
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Client.Session.Tests;
+
+///
+/// An in-memory account server: just-in-time provisioning, enrollment, and /me.
+///
+///
+/// Stores what a real server stores and reports it back the same way, because that round trip is the
+/// thing under test — the provisioner deliberately re-reads /me after enrolling rather than
+/// caching what it believes it sent, and a stub that echoed the request would make that check vacuous.
+///
+/// It does not verify the identity-provider token or the grant signature. Those are the server's job and
+/// are covered against a real JWT pipeline in DodoSSH.Api.Tests; repeating them here would test
+/// this file rather than the client.
+///
+///
+internal sealed class FakeAccountServer : IAccountApi
+{
+ private KeyStatement? statement;
+ private byte[]? wrappedPrivateKey;
+ private KdfParameters? kdfParameters;
+ private VaultSummary? personalVault;
+
+ internal Guid UserId { get; } = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc");
+
+ internal static string Issuer => "https://idp.example/realms/dodossh";
+
+ internal static string Subject => "alice";
+
+ /// The enrollment request as received, so a test can assert what was actually sent.
+ internal EnrollmentRequest? LastEnrollment { get; private set; }
+
+ internal int EnrollmentCount { get; private set; }
+
+ internal int MeCount { get; private set; }
+
+ /// Whether an identity key has been published.
+ internal bool IsEnrolled => statement is not null;
+
+ ///
+ public Task GetMeAsync(CancellationToken cancellationToken)
+ {
+ MeCount++;
+
+ return Task.FromResult(new MeResponse(
+ UserId,
+ Issuer,
+ Subject,
+ "alice@example.com",
+ "Alice",
+ EnrollmentRequired: !IsEnrolled,
+ KeyGeneration: statement?.KeyGeneration,
+ WrappedPrivateKey: wrappedPrivateKey,
+ KdfParameters: kdfParameters,
+ Vaults: personalVault is null ? [] : [personalVault]));
+ }
+
+ ///
+ public Task EnrollAsync(
+ EnrollmentRequest request,
+ CancellationToken cancellationToken)
+ {
+ EnrollmentCount++;
+ LastEnrollment = request;
+
+ if (IsEnrolled)
+ {
+ // The real server answers 409 with ProblemCodes.AlreadyEnrolled. Reproduced because the
+ // provisioner is supposed to never get here — it reads /me first — and a test that changed
+ // that should fail loudly rather than quietly enroll twice.
+ throw new DodoSshApiException(
+ System.Net.HttpStatusCode.Conflict,
+ ProblemCodes.AlreadyEnrolled,
+ "This account already has an identity key.");
+ }
+
+ statement = request.Statement;
+ wrappedPrivateKey = request.WrappedPrivateKey;
+ kdfParameters = request.KdfParameters;
+
+ personalVault = new VaultSummary(
+ request.PersonalVault.VaultId,
+ request.PersonalVault.Name,
+ IsPersonal: true,
+ TeamId: null,
+ KeyGeneration: 1,
+ Permissions: 31,
+ request.PersonalVault.WrappedVaultKey,
+ RekeyRequired: false);
+
+ return Task.FromResult(new EnrollmentResponse(
+ UserId,
+ KeyGeneration: 1,
+ Fingerprint: new byte[32],
+ request.PersonalVault.VaultId,
+ DeviceId: request.DevicePublicKey is null ? null : Guid.CreateVersion7(),
+ KeyLogSequence: 1));
+ }
+
+ /// Drops the vault grant, as a rekey does until it is re-issued.
+ internal void RevokeVaultGrant() =>
+ personalVault = personalVault is null
+ ? null
+ : personalVault with { WrappedVaultKey = null, RekeyRequired = true };
+}
+
+///
+/// Stands in for the identity provider's signature over a key statement.
+///
+///
+/// Records the nonce it was asked for. That the nonce is the statement's hash is what makes the binding
+/// meaningful, and it is asserted in ClientEnrollmentTests; here it only needs to exist.
+///
+internal sealed class StubKeyBinding : IKeyBindingAuthorizer
+{
+ internal string? RequestedNonce { get; private set; }
+
+ public Task AuthorizeKeyBindingAsync(
+ string bindingNonce,
+ CancellationToken cancellationToken)
+ {
+ RequestedNonce = bindingNonce;
+ return Task.FromResult("stub-id-token");
+ }
+}
+
+///
+/// A server with no changes in it.
+///
+///
+/// Enough to prove the session composes a working sync engine. The interesting sync behaviour lives in
+/// DodoSSH.Client.Sync.Tests against a server that enforces version checks; duplicating that here
+/// would be a third implementation of the same decision table.
+///
+internal sealed class EmptySyncApi : ISyncApi
+{
+ internal int PushCount { get; private set; }
+
+ public Task SyncPullAsync(
+ Guid vaultId,
+ SyncPullRequest request,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(new SyncPullResponse(
+ [],
+ request.Cursor ?? "empty-v1:0",
+ HasMore: false,
+ ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000),
+ CurrentKeyGeneration: 1));
+
+ public Task SyncPushAsync(
+ Guid vaultId,
+ SyncPushRequest request,
+ CancellationToken cancellationToken)
+ {
+ PushCount++;
+
+ return Task.FromResult(new SyncPushResponse(
+ [.. request.Operations.Select((operation, index) => new SyncPushResult(
+ operation.OperationId,
+ SyncOperationStatus.Applied,
+ Version: (operation.ExpectedVersion ?? 0) + 1,
+ ChangeSequence: index + 1,
+ ServerEntity: null,
+ Detail: null))],
+ "empty-v1:0"));
+ }
+}
diff --git a/tests/DodoSSH.Client.Session.Tests/SessionLifecycleTests.cs b/tests/DodoSSH.Client.Session.Tests/SessionLifecycleTests.cs
new file mode 100644
index 0000000..96381c5
--- /dev/null
+++ b/tests/DodoSSH.Client.Session.Tests/SessionLifecycleTests.cs
@@ -0,0 +1,311 @@
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Storage;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Session.Tests;
+
+///
+/// Enrolling once, then unlocking with nothing but a passphrase and a file.
+///
+///
+/// The headline property here is that the second half needs no server at all. That is asserted directly:
+/// every unlock in this suite runs against a that has never been given a
+/// transport and could not reach one if it wanted to.
+///
+public sealed class SessionLifecycleTests : IAsyncLifetime
+{
+ private const string Passphrase = "correct horse battery staple";
+ private const string ServerUrl = "https://dodossh.example";
+
+ ///
+ /// Far below the shipped 256 MiB profile. The stretching is what makes a stolen wrap expensive to
+ /// attack and none of these tests attack one; paying a third of a second per derivation — and there
+ /// are three per enroll-and-unlock cycle — would only encourage sharing state between tests.
+ ///
+ private static readonly Argon2Profile CheapProfile =
+ Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
+
+ private readonly FakeAccountServer server = new();
+ private readonly StubKeyBinding keyBinding = new();
+
+ private ClientCacheFactory caches = null!;
+
+ ///
+ public async ValueTask InitializeAsync()
+ {
+ caches = ClientCacheFactory.ForMemory($"session-{Guid.CreateVersion7():N}");
+ await caches.MigrateAsync(TestContext.Current.CancellationToken);
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ caches.Dispose();
+ return ValueTask.CompletedTask;
+ }
+
+ [Fact]
+ public async Task AFreshMachine_HasNothingToUnlock()
+ {
+ (await Opener().ReadProfileAsync(Token)).ShouldBeNull();
+
+ var outcome = await Opener().UnlockAsync(Passphrase, Token);
+
+ outcome.Status.ShouldBe(UnlockStatus.NotEnrolled);
+ outcome.Session.ShouldBeNull();
+ outcome.Message.ShouldContain("not enrolled");
+ }
+
+ [Fact]
+ public async Task EnrollingLeavesEverythingAnOfflineUnlockNeeds()
+ {
+ // The property the whole storage layer exists for. After this point the passphrase alone opens
+ // the vault: no salt is fetched, no grant is fetched, nothing is asked of a server.
+ var provision = await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ provision.Status.ShouldBe(ProvisionStatus.Ready);
+ provision.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
+
+ await using var session = await UnlockAsync();
+
+ session.Profile.UserId.ShouldBe(server.UserId);
+ session.Profile.ServerUrl.ShouldBe(ServerUrl);
+ session.Profile.Issuer.ShouldBe(FakeAccountServer.Issuer);
+ session.Vaults.ShouldHaveSingleItem().Name.ShouldBe("Personal");
+ session.UnreadableVaults.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task TheProfileCanBeReadWithoutThePassphrase()
+ {
+ // So the unlock screen can say who it is asking, rather than showing an unexplained password box.
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ var profile = await Opener().ReadProfileAsync(Token);
+
+ profile.ShouldNotBeNull();
+ profile.Email.ShouldBe("alice@example.com");
+ profile.DisplayName.ShouldBe("Alice");
+ profile.ServerUrl.ShouldBe(ServerUrl);
+ }
+
+ [Fact]
+ public async Task TheWrongPassphrase_IsAnAnswerRatherThanAnException()
+ {
+ // The overwhelmingly common failure. It is also indistinguishable from a tampered wrap, which is
+ // correct: the AEAD tag is the only evidence either way and no verifier is stored anywhere.
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ var outcome = await Opener().UnlockAsync("not the passphrase", Token);
+
+ outcome.Status.ShouldBe(UnlockStatus.WrongPassphrase);
+ outcome.Session.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task NoDeviceKeyIsRegistered()
+ {
+ // A device wrap whose private half has nowhere to live is a row nobody can ever open, and it would
+ // make the account's device list claim this machine can unlock without a passphrase. Until the OS
+ // keystore is wired, not offering it is the honest answer.
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ var request = server.LastEnrollment.ShouldNotBeNull();
+ request.DevicePublicKey.ShouldBeNull();
+ request.DeviceWrappedPrivateKey.ShouldBeNull();
+
+ // The recovery wrap is still registered: it is the only route back if the passphrase is lost.
+ request.RecoveryWrappedPrivateKey.ShouldNotBeNull();
+ request.RecoveryKdfParameters.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task AnAlreadyEnrolledAccount_IsNotEnrolledAgain()
+ {
+ // Re-enrolling would replace an identity key that other members may already have wrapped vault
+ // keys to, which is a far worse outcome than asking for the existing passphrase.
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ var second = await Provisioner().EnrollAsync(ServerUrl, "a different one", "desktop", "Personal", Token);
+
+ second.Status.ShouldBe(ProvisionStatus.Ready);
+ second.RecoveryCode.ShouldBeNull();
+ server.EnrollmentCount.ShouldBe(1);
+
+ // And the original passphrase still works, because nothing was replaced.
+ await using var session = await UnlockAsync();
+ session.Vaults.ShouldHaveSingleItem();
+ }
+
+ [Fact]
+ public async Task SigningInToAnUnenrolledAccount_AsksForEnrollmentRatherThanFailing()
+ {
+ var outcome = await Provisioner().RefreshAsync(ServerUrl, Token);
+
+ outcome.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
+ outcome.Me.EnrollmentRequired.ShouldBeTrue();
+
+ // Nothing was cached, so an unlock still reports honestly.
+ (await Opener().ReadProfileAsync(Token)).ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task RefreshingAnEnrolledAccount_RepairsACacheThatLostItsVaults()
+ {
+ // What signing in on a machine whose cache was cleared looks like. The material comes back from
+ // the server, and the passphrase — which the server never had — opens it again.
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ using var replacement = ClientCacheFactory.ForMemory($"session-{Guid.CreateVersion7():N}");
+ await replacement.MigrateAsync(Token);
+
+ var outcome = await new AccountProvisioner(
+ server, keyBinding, replacement, TimeProvider.System, CheapProfile)
+ .RefreshAsync(ServerUrl, Token);
+
+ outcome.Status.ShouldBe(ProvisionStatus.Ready);
+
+ var unlocked = await new SessionOpener(replacement, TimeProvider.System)
+ .UnlockAsync(Passphrase, Token);
+
+ unlocked.IsUnlocked.ShouldBeTrue(unlocked.Message);
+ await unlocked.Session!.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task AVaultWhoseGrantWasRevoked_SaysSoRatherThanLookingEmpty()
+ {
+ // A rekey this client has not been re-issued for. Reporting a wrong passphrase here would send the
+ // user to retype something that was never the problem.
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ server.RevokeVaultGrant();
+ await Provisioner().RefreshAsync(ServerUrl, Token);
+
+ var outcome = await Opener().UnlockAsync(Passphrase, Token);
+
+ outcome.Status.ShouldBe(UnlockStatus.NoReadableVault);
+ outcome.Message.ShouldContain("rotated");
+ }
+
+ [Fact]
+ public async Task AnUnsupportedKdf_IsNamedRatherThanThrowingFromLibsodium()
+ {
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ var profile = (await Opener().ReadProfileAsync(Token)).ShouldNotBeNull();
+
+ await new UnlockStore(caches, TimeProvider.System).SaveAsync(
+ profile with
+ {
+ KdfParameters = profile.KdfParameters with { Algorithm = "argon2-from-the-future" },
+ },
+ Token);
+
+ var outcome = await Opener().UnlockAsync(Passphrase, Token);
+
+ outcome.Status.ShouldBe(UnlockStatus.UnsupportedKdf);
+ outcome.Message.ShouldContain("argon2-from-the-future");
+ }
+
+ [Fact]
+ public async Task AnUnlockedSession_ReadsAndWritesHostsWithNoServer()
+ {
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ await using var session = await UnlockAsync();
+
+ var entityId = await session.Hosts.CreateAsync(
+ session.ActiveVaultId, Host("prod-db"), Token);
+
+ var listing = await session.Hosts.ListAsync(session.ActiveVaultId, Token);
+
+ var host = listing.Hosts.ShouldHaveSingleItem();
+ host.EntityId.ShouldBe(entityId);
+ host.Host.Label.ShouldBe("prod-db");
+ host.HasUnsyncedChanges.ShouldBeTrue();
+
+ (await session.PendingChangeCountAsync(Token)).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task ASessionSyncsThroughWhicheverTransportItIsHanded()
+ {
+ // The session deliberately holds no transport: losing the network invalidates the connection, not
+ // the vault.
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ await using var session = await UnlockAsync();
+ await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token);
+
+ var transport = new EmptySyncApi();
+ var report = await session.SyncAsync(transport, Token);
+
+ report.Pushed.ShouldBe(1);
+ transport.PushCount.ShouldBe(1);
+ (await session.PendingChangeCountAsync(Token)).ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task ADisposedSession_RefusesToBeUsed()
+ {
+ // Locking is disposing, so this is what "locked" has to mean in practice.
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ var session = await UnlockAsync();
+ var vaultId = session.ActiveVaultId;
+
+ await session.DisposeAsync();
+
+ await Should.ThrowAsync(
+ async () => await session.ReadConflictsAsync(Token));
+
+ await Should.ThrowAsync(
+ async () => await session.Hosts.ListAsync(vaultId, Token));
+
+ // Idempotent, because shutdown paths call it more than once.
+ await session.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task ASecondUnlock_ProducesAnIndependentSession()
+ {
+ // Two windows, or a lock followed by an unlock. Disposing one must not take the other's keys with
+ // it, which it would if anything here were shared statically.
+ await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ var first = await UnlockAsync();
+ await using var second = await UnlockAsync();
+
+ await first.DisposeAsync();
+
+ var listing = await second.Hosts.ListAsync(second.ActiveVaultId, Token);
+ listing.Unreadable.ShouldBe(0);
+ }
+
+ // ---- Helpers ----
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private SessionOpener Opener() => new(caches, TimeProvider.System);
+
+ private AccountProvisioner Provisioner() =>
+ new(server, keyBinding, caches, TimeProvider.System, CheapProfile);
+
+ private async Task UnlockAsync()
+ {
+ var outcome = await Opener().UnlockAsync(Passphrase, Token);
+
+ outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
+ return outcome.Session!;
+ }
+
+ private static HostSecret Host(string label) =>
+ new()
+ {
+ Label = label,
+ Hostname = "db.internal",
+ Port = 22,
+ Username = "deploy",
+ };
+}
diff --git a/tests/DodoSSH.Client.Session.Tests/packages.lock.json b/tests/DodoSSH.Client.Session.Tests/packages.lock.json
new file mode 100644
index 0000000..2eb6ee0
--- /dev/null
+++ b/tests/DodoSSH.Client.Session.Tests/packages.lock.json
@@ -0,0 +1,457 @@
+{
+ "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.session": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Api": "[1.0.0, )",
+ "DodoSSH.Client.Auth": "[1.0.0, )",
+ "DodoSSH.Client.Domain": "[1.0.0, )",
+ "DodoSSH.Client.Storage": "[1.0.0, )",
+ "DodoSSH.Client.Sync": "[1.0.0, )"
+ }
+ },
+ "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.Client.Storage.Tests/FileBackedCacheTests.cs b/tests/DodoSSH.Client.Storage.Tests/FileBackedCacheTests.cs
new file mode 100644
index 0000000..e862dc9
--- /dev/null
+++ b/tests/DodoSSH.Client.Storage.Tests/FileBackedCacheTests.cs
@@ -0,0 +1,87 @@
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Storage.Tests;
+
+///
+/// The cache as it is actually deployed: a file on disk.
+///
+///
+/// Every other suite here uses an in-memory database because it is faster and isolated. That leaves the
+/// production path — , a real migration against a file that does
+/// not exist yet, and data surviving the process that wrote it — untested, which is exactly the shape of
+/// bug that only appears on a user's first launch.
+///
+public sealed class FileBackedCacheTests : IDisposable
+{
+ private readonly string directory =
+ Path.Combine(Path.GetTempPath(), $"dodossh-cache-{Guid.CreateVersion7():N}");
+
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(directory))
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task AMigrationCreatesTheFileAndTheDataOutlivesTheFactory()
+ {
+ Directory.CreateDirectory(directory);
+ var path = Path.Combine(directory, "cache.db");
+
+ var material = Material();
+
+ using (var first = ClientCacheFactory.ForFile(path))
+ {
+ await first.MigrateAsync(Token);
+
+ File.Exists(path).ShouldBeTrue("the migration should have created the database");
+
+ await new UnlockStore(first, TimeProvider.System).SaveAsync(material, Token);
+ }
+
+ // A second factory over the same file, as a later launch of the application is.
+ using var second = ClientCacheFactory.ForFile(path);
+
+ // Migrating again is what every launch does, and it has to be a no-op rather than an error.
+ await second.MigrateAsync(Token);
+
+ var read = await new UnlockStore(second, TimeProvider.System).ReadAsync(Token);
+
+ read.ShouldNotBeNull();
+ read.UserId.ShouldBe(material.UserId);
+ read.WrappedPrivateKey.ShouldBe(material.WrappedPrivateKey);
+ read.KdfParameters.Salt.ShouldBe(material.KdfParameters.Salt);
+ }
+
+ [Fact]
+ public async Task AMissingDirectory_FailsClearlyRatherThanSilently()
+ {
+ // The application creates the profile directory before opening the cache. If that order were ever
+ // reversed, this is the error it would produce — worth pinning so the failure stays diagnosable
+ // instead of turning into an empty vault.
+ using var factory = ClientCacheFactory.ForFile(
+ Path.Combine(directory, "missing", "cache.db"));
+
+ await Should.ThrowAsync(
+ async () => await factory.MigrateAsync(Token));
+ }
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private static StoredUnlockMaterial Material() =>
+ new(
+ "https://dodossh.example",
+ Guid.CreateVersion7(),
+ "https://idp.example",
+ "alice",
+ "alice@example.com",
+ "Alice",
+ KeyGeneration: 1,
+ WrappedPrivateKey: [1, 2, 3, 4],
+ new KdfParameters("argon2id", [5, 6, 7, 8], 262144, 4, 1),
+ DateTimeOffset.FromUnixTimeSeconds(1_750_000_000));
+}