Public Access
Wire the Avalonia shell to the vault
The host list now comes from the vault instead of from a form. A fresh machine takes a server URL, signs in through the browser, enrolls, and from then on opens with the passphrase alone. DodoSSH.Client.Session is the composition layer: where a profile lives, how it unlocks, and how a machine gets one. ClientPaths picks a non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%, because a SQLite cache that roams between two machines is a corrupt one, and each machine's outbox is its own. SessionOpener needs no transport at all and could not reach one if it wanted to; that is the offline unlock, asserted rather than asserted about. A wrong passphrase, a stale KDF and a grant revoked by a rekey are three different answers, because the remedies are three different things and telling someone to retype a passphrase that was never the problem is worse than saying nothing. The shell's states are the onboarding story. The recovery code gets its own state that cannot be clicked past: it exists for one moment, losing it with the passphrase loses the vault, and there is no server-side reset by design. It is dropped from memory on confirmation rather than merely hidden. Sign-in is a delegate over IVaultServer, so the whole state machine runs in a test against an in-memory server — no browser, no identity provider, no toolkit. The view models are plain observable objects, which is what makes that possible. What it does not cover is whether the XAML binds to the right names; that needs a rendered tree and Avalonia.Headless, and is its own piece of work. Three things found by doing it rather than by reading it: - Pooled SQLite connections keep the database file open after the last context is disposed. On Windows that means locked, so the application could never replace its own cache — and a test could not clean up after itself, which is how it surfaced. Dispose now clears the pool. - EF's SQLite provider puts the database in WAL mode, so the cache is three files. A comment in ClientCacheFactory claimed the opposite; reading PRAGMA journal_mode off a real launch settled it. WAL is the right mode here — a sync pass writes while the interface reads — so the comment was wrong on the merits as well as on the fact. - Enrolling a device key with nowhere to keep the private half would put a wrap on the server nobody can open and make the device list claim this machine can unlock without a passphrase. Device binding is now optional and the shell declines it until the OS keystore is wired. Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db and migrated it on first launch, and msedgewebview2 held an established connection to the data plane while the unlock overlay covered it — which is the point of covering the WebView rather than collapsing it, since a NativeWebView that is never laid out is never realised. 630 tests, up from 593. The recovery-code gate and the offline unlock were each verified by breaking them and watching the right test fail. Still to do for M1's actual definition of done: the manual run against the real API and a real Keycloak. Credentials are not a synced entity type yet, so a connection still asks for a password, and the interface says so rather than implying otherwise.
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
<Project Path="src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
|
||||
@@ -29,8 +30,10 @@
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/DodoSSH.Api.Tests/DodoSSH.Api.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Api.Tests/DodoSSH.Client.Api.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Domain.Tests/DodoSSH.Client.Domain.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Session.Tests/DodoSSH.Client.Session.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Storage.Tests/DodoSSH.Client.Storage.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Sync.Tests/DodoSSH.Client.Sync.Tests.csproj" />
|
||||
|
||||
@@ -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.
|
||||
|
||||
+30
-8
@@ -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.
|
||||
|
||||
@@ -15,8 +15,9 @@ namespace DodoSSH.Client.Api;
|
||||
/// <param name="Bundle">The identity key pair, unlocked for this session.</param>
|
||||
/// <param name="PersonalVaultKey">The personal vault's key, in plaintext for this session.</param>
|
||||
/// <param name="DevicePrivateKey">
|
||||
/// 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 <see langword="null"/> 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.
|
||||
/// </param>
|
||||
/// <param name="RecoveryCode">
|
||||
/// 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);
|
||||
|
||||
/// <summary>
|
||||
@@ -46,10 +47,19 @@ public sealed record EnrollmentOutcome(
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClientEnrollment(
|
||||
DodoSshApiClient api,
|
||||
IAccountApi api,
|
||||
IKeyBindingAuthorizer keyBinding,
|
||||
TimeProvider clock)
|
||||
TimeProvider clock,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
{
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private readonly Argon2Profile passphraseProfile = passphraseProfile ?? Argon2Profile.PassphraseDefault;
|
||||
|
||||
/// <summary>Bytes of entropy behind a recovery code.</summary>
|
||||
private const int RecoveryEntropyBytes = 20;
|
||||
|
||||
@@ -63,12 +73,22 @@ public sealed class ClientEnrollment(
|
||||
/// <param name="passphrase">The vault passphrase. Never transmitted or stored.</param>
|
||||
/// <param name="deviceName">Human-readable name for this machine.</param>
|
||||
/// <param name="vaultName">Display name for the personal vault. Plaintext, as vault names are.</param>
|
||||
/// <param name="bindThisDevice">
|
||||
/// Whether to register a device key so a later launch can unlock without the passphrase.
|
||||
/// <para>
|
||||
/// Pass <see langword="false"/> 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.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<EnrollmentOutcome> 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(
|
||||
/// <summary>Secrets the caller keeps after a successful enrollment.</summary>
|
||||
private readonly record struct SessionMaterial(
|
||||
byte[] VaultKey,
|
||||
byte[] DevicePrivateKey,
|
||||
byte[]? DevicePrivateKey,
|
||||
string RecoveryCode);
|
||||
|
||||
/// <remarks>
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,24 @@ public interface IAccessTokenProvider
|
||||
ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account calls: who am I, and publish my first key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separated for the same reason as <see cref="ISyncApi"/>. 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.
|
||||
/// </remarks>
|
||||
public interface IAccountApi
|
||||
{
|
||||
/// <summary>Reads the caller's profile, unlock material and reachable vaults.</summary>
|
||||
Task<MeResponse> GetMeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
|
||||
Task<EnrollmentResponse> EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
|
||||
/// </summary>
|
||||
@@ -58,7 +76,8 @@ public interface ISyncApi
|
||||
/// Everything else carries a bearer token.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// 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 <c>Application</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Everything disposable is a local captured by the closures below rather than a field, because an
|
||||
/// Avalonia <c>Application</c> has no disposal hook of its own and a type that owned them would have
|
||||
/// nowhere honest to release them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,10 +25,19 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
The view models are plain CommunityToolkit.Mvvm objects and need no Avalonia to run, so the shell's
|
||||
state machine is testable as ordinary code. That is the whole reason the sign-in step is a delegate.
|
||||
-->
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.App.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
The renderer's files, including the vendored xterm bundles. Embedded rather than copied to
|
||||
|
||||
@@ -1,146 +1,423 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.App.Terminal;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
|
||||
internal enum ShellState
|
||||
{
|
||||
/// <summary>Reading the cache to find out whether this machine is enrolled.</summary>
|
||||
Starting = 0,
|
||||
|
||||
/// <summary>Nothing is cached. The user has to name a server and sign in, which needs a network.</summary>
|
||||
NeedsServer = 1,
|
||||
|
||||
/// <summary>Signed in, but the account has no vault key yet.</summary>
|
||||
NeedsEnrollment = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Showing the recovery code, and refusing to move on until the user confirms they have it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A separate state rather than a dismissible banner, because this is the only moment the code exists.
|
||||
/// Losing it along with the passphrase means the vault is unrecoverable and there is no server-side
|
||||
/// reset by design — so this is the one screen a user must not be able to click past.
|
||||
/// </remarks>
|
||||
ShowingRecoveryCode = 3,
|
||||
|
||||
/// <summary>Enrolled. The passphrase opens the vault, with or without a network.</summary>
|
||||
Locked = 4,
|
||||
|
||||
/// <summary>Open.</summary>
|
||||
Unlocked = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shell: connect to a host, and surface host key trust decisions.
|
||||
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Hosts are typed in directly for now. Reading them from the encrypted vault needs the local cache
|
||||
/// and the sync client, which are the next pieces; this exists to prove the terminal path end to end
|
||||
/// and is deliberately obvious about being temporary rather than looking like a finished feature.
|
||||
/// The order of these states is the product's onboarding story. A fresh machine needs a server URL and one
|
||||
/// browser sign-in; everything about the identity provider comes from
|
||||
/// <c>/.well-known/dodossh-configuration</c>, so the user never configures an authority or a client id.
|
||||
/// After that the network is optional — the cached salt and wrapped bundle mean the passphrase alone
|
||||
/// unlocks, which is the state the application spends nearly all of its life in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two host key states are modelled separately and behave differently, which is the point. An
|
||||
/// unknown host offers a Trust button. A changed key offers nothing — see
|
||||
/// <see cref="SshHostKeyMismatchException"/> for why there is no "continue anyway" here.
|
||||
/// Key derivation runs on a worker thread. At the shipped profile it is a third of a second of solid CPU,
|
||||
/// and doing that on the UI thread would freeze the window at exactly the moment the user is watching it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class MainWindowViewModel(
|
||||
TerminalWorkspace workspace,
|
||||
IKnownHostStore knownHosts) : ObservableObject
|
||||
internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string host = "127.0.0.1";
|
||||
private readonly ClientPaths paths;
|
||||
private readonly ClientCacheFactory caches;
|
||||
private readonly TerminalWorkspace workspace;
|
||||
private readonly IKnownHostStore knownHosts;
|
||||
private readonly SignInHandler signIn;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Argon2Profile? passphraseProfile;
|
||||
|
||||
private IVaultServer? connection;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Establishes a connection to a server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A delegate rather than a direct call to <see cref="ServerConnection.SignInAsync"/>, so this whole
|
||||
/// state machine can be driven by a test against an in-memory server. Sign-in is the one step that
|
||||
/// genuinely needs a browser and a network, and letting it be the reason nothing else is testable
|
||||
/// would be the wrong trade.
|
||||
/// </remarks>
|
||||
internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
|
||||
|
||||
internal MainWindowViewModel(
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
TerminalWorkspace workspace,
|
||||
IKnownHostStore knownHosts,
|
||||
SignInHandler signIn,
|
||||
TimeProvider clock,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
{
|
||||
this.paths = paths;
|
||||
this.caches = caches;
|
||||
this.workspace = workspace;
|
||||
this.knownHosts = knownHosts;
|
||||
this.signIn = signIn;
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private int port = 22;
|
||||
private ShellState state = ShellState.Starting;
|
||||
|
||||
[ObservableProperty]
|
||||
private string username = string.Empty;
|
||||
private string statusMessage = "Opening the local cache…";
|
||||
|
||||
[ObservableProperty]
|
||||
private string password = string.Empty;
|
||||
private bool isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = "Enter a host and connect.";
|
||||
private string serverUrl = "https://localhost:7217";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isConnecting;
|
||||
private string passphrase = string.Empty;
|
||||
|
||||
/// <summary>The key awaiting the user's decision, or null when there is none.</summary>
|
||||
[ObservableProperty]
|
||||
private HostKeyPresentation? pendingHostKey;
|
||||
private string confirmPassphrase = string.Empty;
|
||||
|
||||
/// <summary>Set when a pinned key changed, which is a dead end rather than a prompt.</summary>
|
||||
/// <summary>Shown once, immediately after enrolling, and never stored anywhere.</summary>
|
||||
[ObservableProperty]
|
||||
private string? hostKeyMismatch;
|
||||
private string? recoveryCode;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool recoveryCodeWrittenDown;
|
||||
|
||||
/// <summary>Who this machine is enrolled as, readable without the passphrase.</summary>
|
||||
[ObservableProperty]
|
||||
private string? accountName;
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultViewModel? vault;
|
||||
|
||||
/// <summary>Where the embedded browser should navigate.</summary>
|
||||
public Uri TerminalPageUrl => workspace.PageUrl;
|
||||
internal Uri TerminalPageUrl => workspace.PageUrl;
|
||||
|
||||
/// <summary>Whether the trust prompt should be visible.</summary>
|
||||
public bool HasPendingHostKey => PendingHostKey is not null;
|
||||
internal bool IsStarting => State == ShellState.Starting;
|
||||
|
||||
/// <summary>Whether the mismatch banner should be visible.</summary>
|
||||
public bool HasHostKeyMismatch => HostKeyMismatch is not null;
|
||||
internal bool IsNeedingServer => State == ShellState.NeedsServer;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
internal bool IsNeedingEnrollment => State == ShellState.NeedsEnrollment;
|
||||
|
||||
internal bool IsShowingRecoveryCode => State == ShellState.ShowingRecoveryCode;
|
||||
|
||||
internal bool IsLocked => State == ShellState.Locked;
|
||||
|
||||
internal bool IsUnlocked => State == ShellState.Unlocked;
|
||||
|
||||
/// <summary>Whether a connection to the server is currently held.</summary>
|
||||
internal bool IsOnline => connection is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Brings the schema up to date and works out which screen to show.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Migrating happens before unlock and touches no encrypted content — only the shape of the tables.
|
||||
/// That is the point of migrating rather than recreating: a user who upgrades while offline must still
|
||||
/// be able to open their vault.
|
||||
/// </remarks>
|
||||
internal async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Username))
|
||||
{
|
||||
Status = "A username is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsConnecting = true;
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
Status = $"Connecting to {Host}:{Port}…";
|
||||
|
||||
try
|
||||
{
|
||||
// The renderer has to be attached first: the transport drops frames when nothing is
|
||||
// connected, so a session opened earlier would lose its SessionOpened frame and then
|
||||
// stream output at a terminal that was never created.
|
||||
await workspace.WaitForRendererAsync().ConfigureAwait(true);
|
||||
paths.EnsureCreated();
|
||||
await caches.MigrateAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
var request = new SshConnectionRequest(
|
||||
Host,
|
||||
Port,
|
||||
Username,
|
||||
new SshPasswordCredential(Password));
|
||||
var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
await workspace
|
||||
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
if (profile is null)
|
||||
{
|
||||
State = ShellState.NeedsServer;
|
||||
StatusMessage = "Sign in to a DodoSSH server to set this machine up.";
|
||||
return;
|
||||
}
|
||||
|
||||
Status = $"Connected to {Host}:{Port}.";
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
// First contact. The user has to decide, and they need the fingerprint to do it.
|
||||
PendingHostKey = exception.Presentation;
|
||||
Status = "This host has not been seen before.";
|
||||
}
|
||||
catch (SshHostKeyMismatchException exception)
|
||||
{
|
||||
HostKeyMismatch = exception.Message;
|
||||
Status = "The host key has changed. The connection was refused.";
|
||||
AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
|
||||
ServerUrl = profile.ServerUrl;
|
||||
State = ShellState.Locked;
|
||||
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsConnecting = false;
|
||||
State = ShellState.NeedsServer;
|
||||
StatusMessage = $"The local cache could not be opened: {exception.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pins the offered key and retries.</summary>
|
||||
/// <summary>Discovers the server and runs the browser sign-in.</summary>
|
||||
[RelayCommand]
|
||||
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
|
||||
private async Task SignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (PendingHostKey is not { } presentation)
|
||||
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var url))
|
||||
{
|
||||
StatusMessage = "That is not a valid server URL.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Opening your browser to sign in…",
|
||||
async () =>
|
||||
{
|
||||
connection?.Dispose();
|
||||
connection = null;
|
||||
|
||||
connection = await signIn(url, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
|
||||
var outcome = await Provisioner()!
|
||||
.RefreshAsync(ServerUrl, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
|
||||
StatusMessage = outcome.Message;
|
||||
|
||||
State = outcome.Status == ProvisionStatus.EnrollmentRequired
|
||||
? ShellState.NeedsEnrollment
|
||||
: ShellState.Locked;
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Creates the identity key and the personal vault.</summary>
|
||||
[RelayCommand]
|
||||
private async Task EnrollAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Provisioner() is not { } provisioner)
|
||||
{
|
||||
StatusMessage = "Sign in first.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ValidateNewPassphrase())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
|
||||
await RunAsync(
|
||||
"Creating your vault. This deliberately takes a moment…",
|
||||
async () =>
|
||||
{
|
||||
var chosen = Passphrase;
|
||||
|
||||
PendingHostKey = null;
|
||||
var outcome = await Task
|
||||
.Run(
|
||||
() => provisioner.EnrollAsync(
|
||||
ServerUrl,
|
||||
chosen,
|
||||
Environment.MachineName,
|
||||
"Personal",
|
||||
cancellationToken),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ConnectAsync(cancellationToken).ConfigureAwait(true);
|
||||
ConfirmPassphrase = string.Empty;
|
||||
RecoveryCode = outcome.RecoveryCode;
|
||||
RecoveryCodeWrittenDown = false;
|
||||
StatusMessage = outcome.Message;
|
||||
|
||||
// A brand-new account always yields a code. An account someone else already enrolled does
|
||||
// not, and there is nothing to show.
|
||||
State = RecoveryCode is null ? ShellState.Locked : ShellState.ShowingRecoveryCode;
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
|
||||
/// <summary>Leaves the recovery-code screen, once the user says they have it.</summary>
|
||||
[RelayCommand]
|
||||
private void RejectHostKey()
|
||||
private void ConfirmRecoveryCode()
|
||||
{
|
||||
PendingHostKey = null;
|
||||
Status = "The host key was not trusted, so nothing was connected.";
|
||||
if (!RecoveryCodeWrittenDown)
|
||||
{
|
||||
StatusMessage = "Confirm you have written the recovery code down first.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleared from memory as well as from the screen. It was never persisted, and keeping it in a view
|
||||
// model for the rest of the session would undo that.
|
||||
RecoveryCode = null;
|
||||
State = ShellState.Locked;
|
||||
StatusMessage = "Unlock with the passphrase you just chose.";
|
||||
}
|
||||
|
||||
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
||||
OnPropertyChanged(nameof(HasPendingHostKey));
|
||||
/// <summary>Opens the vault.</summary>
|
||||
[RelayCommand]
|
||||
private async Task UnlockAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Passphrase.Length == 0)
|
||||
{
|
||||
StatusMessage = "Enter your vault passphrase.";
|
||||
return;
|
||||
}
|
||||
|
||||
partial void OnHostKeyMismatchChanged(string? value) =>
|
||||
OnPropertyChanged(nameof(HasHostKeyMismatch));
|
||||
await RunAsync(
|
||||
"Unlocking…",
|
||||
async () =>
|
||||
{
|
||||
var entered = Passphrase;
|
||||
|
||||
// Off the UI thread: Argon2id at the shipped profile is a third of a second of solid CPU
|
||||
// and would otherwise freeze the window mid-unlock.
|
||||
var outcome = await Task
|
||||
.Run(() => Opener().UnlockAsync(entered, cancellationToken), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
StatusMessage = outcome.Message;
|
||||
|
||||
if (!outcome.IsUnlocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Passphrase = string.Empty;
|
||||
|
||||
Vault = new VaultViewModel(outcome.Session!, workspace, knownHosts, () => connection);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Closes the vault and forgets every key it held.</summary>
|
||||
[RelayCommand]
|
||||
private async Task LockAsync()
|
||||
{
|
||||
if (Vault is { } open)
|
||||
{
|
||||
Vault = null;
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
State = ShellState.Locked;
|
||||
StatusMessage = "Locked.";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
if (Vault is { } open)
|
||||
{
|
||||
await open.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
connection?.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The passphrase is the entire defence for the vault — docs/crypto.md §2 says so plainly, and no
|
||||
/// server-side reset exists. A length floor is a crude check and still the one that matters most.
|
||||
/// </remarks>
|
||||
private bool ValidateNewPassphrase()
|
||||
{
|
||||
if (Passphrase.Length < 12)
|
||||
{
|
||||
StatusMessage = "Use a passphrase of at least 12 characters.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(Passphrase, ConfirmPassphrase, StringComparison.Ordinal))
|
||||
{
|
||||
StatusMessage = "The two passphrases do not match.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Sync limits come from the server when there is one, so a batch is never larger than this
|
||||
/// particular deployment accepts. Offline, the defaults apply and nothing is pushed anyway.
|
||||
/// </remarks>
|
||||
private SessionOpener Opener() => new(caches, clock, connection?.SyncOptions);
|
||||
|
||||
private AccountProvisioner? Provisioner() =>
|
||||
connection is null
|
||||
? null
|
||||
: new AccountProvisioner(
|
||||
connection.Account, connection.KeyBinding, caches, clock, passphraseProfile);
|
||||
|
||||
/// <remarks>
|
||||
/// Every command funnels through here so the busy flag and the failure message are handled once. A
|
||||
/// command that forgot either would leave the window permanently disabled or silently doing nothing.
|
||||
/// </remarks>
|
||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = busyMessage;
|
||||
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StatusMessage = "Cancelled.";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
StatusMessage = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnStateChanged(ShellState value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
OnPropertyChanged(nameof(IsNeedingServer));
|
||||
OnPropertyChanged(nameof(IsNeedingEnrollment));
|
||||
OnPropertyChanged(nameof(IsShowingRecoveryCode));
|
||||
OnPropertyChanged(nameof(IsLocked));
|
||||
OnPropertyChanged(nameof(IsUnlocked));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Client.Terminal;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>One host, as a row in the list.</summary>
|
||||
/// <remarks>
|
||||
/// Carries the decrypted <see cref="HostSecret"/> so opening the editor needs no second decryption, and
|
||||
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
|
||||
/// an item a newer client wrote that must not be re-encoded here.
|
||||
/// </remarks>
|
||||
internal sealed class HostRowViewModel(VaultHost host)
|
||||
{
|
||||
internal Guid EntityId => host.EntityId;
|
||||
|
||||
internal HostSecret Host => host.Host;
|
||||
|
||||
internal string Label => host.Host.Label;
|
||||
|
||||
internal string Address => string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{host.Host.Username ?? "—"}@{host.Host.Hostname}:{host.Host.Port}");
|
||||
|
||||
internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;
|
||||
|
||||
internal bool IsBlocked => host.IsBlocked;
|
||||
|
||||
internal bool IsReadOnly => host.IsReadOnly;
|
||||
|
||||
/// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
|
||||
internal string Badge => host switch
|
||||
{
|
||||
{ IsBlocked: true } => "rejected",
|
||||
{ IsReadOnly: true } => "newer version",
|
||||
{ HasUnsyncedChanges: true } => "not synced",
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A conflict, as a row.</summary>
|
||||
internal sealed class ConflictRowViewModel(ConflictNotice notice)
|
||||
{
|
||||
internal Guid Id => notice.Id;
|
||||
|
||||
internal string Summary => notice.Summary;
|
||||
|
||||
/// <summary>
|
||||
/// The overridden values, one line each.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the whole justification for resolving a conflict automatically. If these were not shown,
|
||||
/// the merge would be last-writer-wins with a longer explanation.
|
||||
/// </remarks>
|
||||
internal string Detail => notice.Fields.Count == 0
|
||||
? string.Empty
|
||||
: string.Join(
|
||||
Environment.NewLine,
|
||||
// Not named 'field': C# 14 made that a contextual keyword inside a property accessor, and
|
||||
// this whole expression is one.
|
||||
notice.Fields.Select(entry => entry.DiscardedWasRemoval
|
||||
? $"{entry.Field}: a removal was overridden; '{entry.Kept}' was kept"
|
||||
: $"{entry.Field}: kept '{entry.Kept}', discarded '{entry.Discarded}'"));
|
||||
|
||||
internal bool HasDetail => notice.Fields.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The list is the local mirror with unpushed changes laid over it, so an edit appears immediately and a
|
||||
/// delete disappears immediately whether or not the network is there. Syncing is a separate, explicit
|
||||
/// action; nothing here blocks on a server.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Credentials are not in the vault yet.</b> <c>SyncEntityType.Credential</c> exists in the contract
|
||||
/// but is not synced, so connecting still asks for a password each time. That is a real M1 limitation
|
||||
/// rather than a design choice, and the interface says so rather than implying the vault holds more than
|
||||
/// it does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class VaultViewModel(
|
||||
VaultSession session,
|
||||
TerminalWorkspace workspace,
|
||||
IKnownHostStore knownHosts,
|
||||
Func<IVaultServer?> connection) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>The hosts to show, unpushed local state included.</summary>
|
||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||
|
||||
/// <summary>Whatever the merge had to override and the user has not acknowledged.</summary>
|
||||
internal ObservableCollection<ConflictRowViewModel> Conflicts { get; } = [];
|
||||
|
||||
internal string VaultName =>
|
||||
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Vault";
|
||||
|
||||
[ObservableProperty]
|
||||
private HostRowViewModel? selectedHost;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private int pendingChanges;
|
||||
|
||||
[ObservableProperty]
|
||||
private int unreadableItems;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
// ---- The editor ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isEditing;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorHostname = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private int editorPort = HostSecret.DefaultPort;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorUsername = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorNotes = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool editorRelayEnabled;
|
||||
|
||||
/// <summary>The item being edited, or null when creating.</summary>
|
||||
private Guid? editingEntityId;
|
||||
|
||||
// ---- Connecting ----
|
||||
|
||||
/// <remarks>
|
||||
/// Typed per connection because credentials are not a synced entity type yet. Never persisted.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string connectPassword = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private HostKeyPresentation? pendingHostKey;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? hostKeyMismatch;
|
||||
|
||||
internal bool HasPendingHostKey => PendingHostKey is not null;
|
||||
|
||||
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
|
||||
|
||||
internal bool HasConflicts => Conflicts.Count > 0;
|
||||
|
||||
/// <summary>Reads the vault into the list.</summary>
|
||||
internal async Task LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.Hosts
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var selectedId = SelectedHost?.EntityId;
|
||||
|
||||
Hosts.Clear();
|
||||
|
||||
foreach (var host in listing.Hosts.OrderBy(host => host.Host.Label, StringComparer.CurrentCulture))
|
||||
{
|
||||
Hosts.Add(new HostRowViewModel(host));
|
||||
}
|
||||
|
||||
// Selection survives a reload. Losing it on every sync would move the terminal's target out from
|
||||
// under the user.
|
||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
|
||||
|
||||
UnreadableItems = listing.Unreadable;
|
||||
PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = Hosts.Count == 0
|
||||
? "No hosts yet. Add one."
|
||||
: $"{Hosts.Count} host(s) in {VaultName}.";
|
||||
}
|
||||
|
||||
/// <summary>Runs a synchronisation pass, if there is a server to talk to.</summary>
|
||||
[RelayCommand]
|
||||
private async Task SyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Status = "Offline. Changes are queued and will be sent after you sign in.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Synchronising…",
|
||||
async () =>
|
||||
{
|
||||
var report = await session.SyncAsync(server.Sync, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
await LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = Describe(report);
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Starts a new host.</summary>
|
||||
[RelayCommand]
|
||||
private void NewHost()
|
||||
{
|
||||
editingEntityId = null;
|
||||
EditorLabel = string.Empty;
|
||||
EditorHostname = string.Empty;
|
||||
EditorPort = HostSecret.DefaultPort;
|
||||
EditorUsername = string.Empty;
|
||||
EditorNotes = string.Empty;
|
||||
EditorRelayEnabled = false;
|
||||
IsEditing = true;
|
||||
Status = "Adding a host.";
|
||||
}
|
||||
|
||||
/// <summary>Opens the selected host for editing.</summary>
|
||||
[RelayCommand]
|
||||
private void EditSelectedHost()
|
||||
{
|
||||
if (SelectedHost is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
// Re-encoding would drop fields this build has no concept of, so the honest answer is to
|
||||
// refuse rather than to silently lose a colleague's data.
|
||||
Status = "This host was written by a newer version of DodoSSH. Update before editing it.";
|
||||
return;
|
||||
}
|
||||
|
||||
editingEntityId = row.EntityId;
|
||||
EditorLabel = row.Host.Label;
|
||||
EditorHostname = row.Host.Hostname;
|
||||
EditorPort = row.Host.Port;
|
||||
EditorUsername = row.Host.Username ?? string.Empty;
|
||||
EditorNotes = row.Host.Notes ?? string.Empty;
|
||||
EditorRelayEnabled = row.Host.RelayEnabled;
|
||||
IsEditing = true;
|
||||
Status = $"Editing {row.Label}.";
|
||||
}
|
||||
|
||||
/// <summary>Abandons the editor.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelEdit()
|
||||
{
|
||||
IsEditing = false;
|
||||
editingEntityId = null;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Stores the editor's contents, encrypted, and queues it for the server.</summary>
|
||||
[RelayCommand]
|
||||
private async Task SaveHostAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var host = BuildHost();
|
||||
|
||||
if (!host.TryValidate(out var error))
|
||||
{
|
||||
Status = error;
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Saving…",
|
||||
async () =>
|
||||
{
|
||||
if (editingEntityId is { } entityId)
|
||||
{
|
||||
await session.Hosts
|
||||
.UpdateAsync(session.ActiveVaultId, entityId, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
editingEntityId = await session.Hosts
|
||||
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
IsEditing = false;
|
||||
await LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
|
||||
editingEntityId = null;
|
||||
|
||||
Status = $"Saved '{host.Label}'. It will sync when you are online.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the selected host.</summary>
|
||||
[RelayCommand]
|
||||
private async Task DeleteHostAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedHost is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Deleting…",
|
||||
async () =>
|
||||
{
|
||||
await session.Hosts
|
||||
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
Status = $"Deleted '{row.Label}'.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Opens a terminal on the selected host.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedHost is not { } row)
|
||||
{
|
||||
Status = "Choose a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(row.Host.Username))
|
||||
{
|
||||
Status = "This host has no username. Edit it and add one.";
|
||||
return;
|
||||
}
|
||||
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Connecting to {row.Label}…",
|
||||
() => OpenSessionAsync(row, cancellationToken)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Pins the offered host key and retries.</summary>
|
||||
[RelayCommand]
|
||||
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (PendingHostKey is not { } presentation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
PendingHostKey = null;
|
||||
|
||||
await ConnectAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
|
||||
[RelayCommand]
|
||||
private void RejectHostKey()
|
||||
{
|
||||
PendingHostKey = null;
|
||||
Status = "The host key was not trusted, so nothing was connected.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks every shown conflict as seen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Acknowledged rather than deleted, so the discarded values stay retrievable afterwards. Someone who
|
||||
/// dismisses this and realises a minute later that they wanted the other value should still be able to
|
||||
/// get it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task AcknowledgeAllConflictsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var conflict in Conflicts.ToArray())
|
||||
{
|
||||
await session.AcknowledgeConflictAsync(conflict.Id, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The renderer has to be attached before a session opens: the transport drops frames when nothing is
|
||||
/// connected, so a session opened earlier would lose its <c>SessionOpened</c> frame and then stream
|
||||
/// output at a terminal that was never created.
|
||||
/// </remarks>
|
||||
private async Task OpenSessionAsync(HostRowViewModel row, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await workspace.WaitForRendererAsync().ConfigureAwait(true);
|
||||
|
||||
var request = new SshConnectionRequest(
|
||||
row.Host.Hostname,
|
||||
row.Host.Port,
|
||||
row.Host.Username!,
|
||||
new SshPasswordCredential(ConnectPassword));
|
||||
|
||||
await workspace
|
||||
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}.";
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
// First contact. The user has to decide, and they need the fingerprint to do it.
|
||||
PendingHostKey = exception.Presentation;
|
||||
Status = "This host has not been seen before.";
|
||||
}
|
||||
catch (SshHostKeyMismatchException exception)
|
||||
{
|
||||
HostKeyMismatch = exception.Message;
|
||||
Status = "The host key has changed. The connection was refused.";
|
||||
}
|
||||
}
|
||||
|
||||
private HostSecret BuildHost() =>
|
||||
new()
|
||||
{
|
||||
Label = EditorLabel.Trim(),
|
||||
Hostname = EditorHostname.Trim(),
|
||||
Port = EditorPort,
|
||||
Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
||||
RelayEnabled = EditorRelayEnabled,
|
||||
};
|
||||
|
||||
private async Task LoadConflictsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var notices = await session.ReadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Conflicts.Clear();
|
||||
|
||||
foreach (var notice in notices)
|
||||
{
|
||||
Conflicts.Add(new ConflictRowViewModel(notice));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasConflicts));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Deliberately reports the things a user has to act on rather than a count of successes. A pass that
|
||||
/// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point
|
||||
/// of recording those is that somebody sees them.
|
||||
/// </remarks>
|
||||
private static string Describe(SyncReport report)
|
||||
{
|
||||
if (!report.NeedsAttention)
|
||||
{
|
||||
return report.Pulled == 0 && report.Pushed == 0
|
||||
? "Already up to date."
|
||||
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.";
|
||||
}
|
||||
|
||||
var notes = new List<string>();
|
||||
|
||||
if (report.Resurrected > 0)
|
||||
{
|
||||
notes.Add($"{report.Resurrected} host(s) deleted elsewhere were kept under a new name");
|
||||
}
|
||||
|
||||
if (report.DeletesAbandoned > 0)
|
||||
{
|
||||
notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
|
||||
}
|
||||
|
||||
if (report.Parked > 0)
|
||||
{
|
||||
notes.Add($"{report.Parked} change(s) were refused and need attention");
|
||||
}
|
||||
|
||||
if (report.Unreadable > 0)
|
||||
{
|
||||
notes.Add($"{report.Unreadable} item(s) could not be decrypted");
|
||||
}
|
||||
|
||||
if (report.RekeyRequired)
|
||||
{
|
||||
notes.Add("this vault was rekeyed and your access needs re-issuing");
|
||||
}
|
||||
|
||||
return "Synchronised, but: " + string.Join("; ", notes) + ".";
|
||||
}
|
||||
|
||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
Status = busyMessage;
|
||||
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Status = "Cancelled.";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
||||
OnPropertyChanged(nameof(HasPendingHostKey));
|
||||
|
||||
partial void OnHostKeyMismatchChanged(string? value) =>
|
||||
OnPropertyChanged(nameof(HasHostKeyMismatch));
|
||||
}
|
||||
@@ -4,72 +4,291 @@
|
||||
x:Class="DodoSSH.Client.App.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="DodoSSH"
|
||||
Width="1100"
|
||||
Height="720"
|
||||
MinWidth="640"
|
||||
MinHeight="400"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="820"
|
||||
MinHeight="520"
|
||||
Background="#10131a">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<Window.Styles>
|
||||
<Style Selector="TextBlock.hint">
|
||||
<Setter Property="Foreground" Value="#7b8394" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.heading">
|
||||
<Setter Property="Foreground" Value="#e6e9f0" />
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="#171b24" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="24" />
|
||||
<Setter Property="MaxWidth" Value="520" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<!--
|
||||
The terminal's WebView stays in the visual tree at all times and is covered by the setup and unlock
|
||||
screens rather than being collapsed. A NativeWebView hosts a real child window, and hiding it means
|
||||
never realising it — which would leave the terminal blank on the first connection after unlocking.
|
||||
-->
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,*" ColumnDefinitions="340,*">
|
||||
|
||||
<!-- Account bar -->
|
||||
<Border Grid.Row="0" Grid.ColumnSpan="2" Padding="12,8" Background="#171b24"
|
||||
IsVisible="{Binding IsUnlocked}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Vault.VaultName}" Foreground="#e6e9f0" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding AccountName}" Classes="hint" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Vault.Status}" Classes="hint" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" MaxWidth="520" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="offline" Foreground="#c8a55a" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !IsOnline}" />
|
||||
<Button Content="Sign in" Command="{Binding SignInCommand}"
|
||||
IsVisible="{Binding !IsOnline}" />
|
||||
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
|
||||
<Button Content="Lock" Command="{Binding LockCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Host list -->
|
||||
<Grid Grid.Row="1" Grid.Column="0" RowDefinitions="*,Auto,Auto"
|
||||
Background="#131722" IsVisible="{Binding IsUnlocked}">
|
||||
|
||||
<ListBox Grid.Row="0" Margin="6"
|
||||
ItemsSource="{Binding Vault.Hosts}"
|
||||
SelectedItem="{Binding Vault.SelectedHost}"
|
||||
Background="Transparent">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="2,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
|
||||
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Address}" Classes="hint" FontSize="11"
|
||||
FontFamily="ui-monospace,Consolas,monospace" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
|
||||
<Border Grid.Row="1" Padding="10" Background="#171b24" IsVisible="{Binding Vault.IsEditing}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBox Text="{Binding Vault.EditorLabel}" PlaceholderText="name" />
|
||||
<TextBox Text="{Binding Vault.EditorHostname}" PlaceholderText="hostname or address" />
|
||||
<NumericUpDown Value="{Binding Vault.EditorPort}" Minimum="1" Maximum="65535"
|
||||
FormatString="0" />
|
||||
<TextBox Text="{Binding Vault.EditorUsername}" PlaceholderText="username" />
|
||||
<TextBox Text="{Binding Vault.EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="60" TextWrapping="Wrap" />
|
||||
<CheckBox IsChecked="{Binding Vault.EditorRelayEnabled}"
|
||||
Content="Allow connecting through the server relay" />
|
||||
<!--
|
||||
Stated at the moment the decision is made, which is the only place it means anything. With
|
||||
relay off the server stores no address at all; with it on the server must be able to resolve
|
||||
the target, or it becomes an authenticated open proxy into the operator's network.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Enabling the relay stores this host's address on the server in plain text. Everything else about the host stays encrypted." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Save" Command="{Binding Vault.SaveHostCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding Vault.CancelEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8"
|
||||
IsVisible="{Binding !Vault.IsEditing}">
|
||||
<Button Content="Add" Command="{Binding Vault.NewHostCommand}" />
|
||||
<Button Content="Edit" Command="{Binding Vault.EditSelectedHostCommand}" />
|
||||
<Button Content="Delete" Command="{Binding Vault.DeleteHostCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- Terminal column -->
|
||||
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="10,8" Background="#171b24" IsVisible="{Binding IsUnlocked}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<!--
|
||||
Typed per connection. SyncEntityType.Credential exists in the contract but is not synced
|
||||
yet, so the vault genuinely does not hold this — saying so beats a password box that looks
|
||||
like it should have been remembered.
|
||||
-->
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored yet)"
|
||||
PasswordChar="•" Width="220" VerticalAlignment="Center" />
|
||||
<Button Content="Connect" Command="{Binding Vault.ConnectCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="hint" FontSize="11" VerticalAlignment="Center"
|
||||
Text="Credentials are not in the vault yet." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="1" IsVisible="{Binding IsUnlocked}">
|
||||
|
||||
<!--
|
||||
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
|
||||
other is a refusal. Presenting a changed key with a "continue" button is how users are taught
|
||||
to click through the one warning that matters.
|
||||
-->
|
||||
<Border Padding="10,8" Background="#2b2410" IsVisible="{Binding Vault.HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="#e8dcb0" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
Foreground="#f4ecd0" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Trust and connect" Command="{Binding Vault.TrustHostKeyCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding Vault.RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="10,8" Background="#3a1418" IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="#f3c9cd" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
||||
Foreground="#f3c9cd" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, remove its pinned key in the host's settings first. There is deliberately no way to continue from here."
|
||||
Foreground="#d59aa1" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode is
|
||||
kept and shown; without this panel it would be last-writer-wins with a longer explanation.
|
||||
-->
|
||||
<Border Padding="10,8" Background="#1b2432" IsVisible="{Binding Vault.HasConflicts}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Some changes could not be merged automatically."
|
||||
Foreground="#bcd2ea" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
||||
<Border Margin="0,4" Padding="8" Background="#141b26" CornerRadius="4">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Summary}" Foreground="#dfe6f0" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Text="{Binding Detail}" Classes="hint" FontSize="11"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
IsVisible="{Binding HasDetail}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Content="Dismiss all" Command="{Binding Vault.AcknowledgeAllConflictsCommand}"
|
||||
HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser process
|
||||
tree, so twenty tabs would cost twenty of them.
|
||||
-->
|
||||
<NativeWebView Grid.Row="2" x:Name="Terminal" />
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- Setup and unlock, over the top. -->
|
||||
<Border Background="#10131a" IsVisible="{Binding !IsUnlocked}">
|
||||
|
||||
<Panel>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsStarting}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="heading" Text="DodoSSH" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsNeedingServer}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Connect to your server" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="One address is all this needs. The identity provider, the client id and the scopes all come from the server itself." />
|
||||
<TextBox Text="{Binding ServerUrl}" PlaceholderText="https://dodossh.example" />
|
||||
<Button Content="Sign in with your browser" Command="{Binding SignInCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Choose a vault passphrase" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your vault." />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
||||
<Button Content="Create my vault" Command="{Binding EnrollCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Shown once and impossible to skip. This is the only moment the code exists, and losing it
|
||||
together with the passphrase means the vault is unrecoverable — there is no server-side reset by
|
||||
design.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Write this recovery code down" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the vault: nobody — including whoever runs the server — can recover it for you." />
|
||||
<Border Background="#0c0f15" CornerRadius="6" Padding="14">
|
||||
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
FontSize="16" Foreground="#9ee6b4" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
|
||||
Content="I have written it down somewhere safe" />
|
||||
<Button Content="Continue" Command="{Binding ConfirmRecoveryCodeCommand}"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsLocked}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="#bcd2ea" />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<Button Content="Unlock" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!-- Connection bar. Replaced by the host list once the vault is wired up. -->
|
||||
<Border Grid.Row="0" Padding="10,8" Background="#171b24">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding Host}" PlaceholderText="host" Width="200" VerticalAlignment="Center" />
|
||||
<NumericUpDown Value="{Binding Port}" Minimum="1" Maximum="65535"
|
||||
FormatString="0" Width="110" VerticalAlignment="Center" />
|
||||
<TextBox Text="{Binding Username}" PlaceholderText="username" Width="150" VerticalAlignment="Center" />
|
||||
<TextBox Text="{Binding Password}" PlaceholderText="password" PasswordChar="•"
|
||||
Width="170" VerticalAlignment="Center" />
|
||||
<Button Content="Connect"
|
||||
Command="{Binding ConnectCommand}"
|
||||
IsEnabled="{Binding !IsConnecting}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Status}" Foreground="#7b8394"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
|
||||
other is a refusal. Presenting a changed key with a "continue" button is how users are taught
|
||||
to click through the one warning that matters.
|
||||
-->
|
||||
<StackPanel Grid.Row="1">
|
||||
|
||||
<Border Padding="10,8" Background="#2b2410" IsVisible="{Binding HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="#e8dcb0" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Text="{Binding PendingHostKey.Fingerprint}"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
Foreground="#f4ecd0" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Trust and connect" Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="10,8" Background="#3a1418" IsVisible="{Binding HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="#f3c9cd" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding HostKeyMismatch}"
|
||||
Foreground="#f3c9cd" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, remove its pinned key in the host's settings first. There is deliberately no way to continue from here."
|
||||
Foreground="#d59aa1" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
|
||||
process, so twenty tabs would cost twenty renderer processes.
|
||||
-->
|
||||
<NativeWebView Grid.Row="2" x:Name="Terminal" />
|
||||
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
</Window>
|
||||
|
||||
@@ -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, )",
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>What the server said about this account.</summary>
|
||||
public enum ProvisionStatus
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>
|
||||
/// No identity key exists yet. The user must choose a passphrase and enroll before anything else
|
||||
/// works.
|
||||
/// </summary>
|
||||
EnrollmentRequired = 1,
|
||||
|
||||
/// <summary>Enrolled, and everything an offline unlock needs is now cached.</summary>
|
||||
Ready = 2,
|
||||
}
|
||||
|
||||
/// <summary>The result of talking to the server about this account.</summary>
|
||||
/// <param name="Status">What happened.</param>
|
||||
/// <param name="Me">The profile the server reported.</param>
|
||||
/// <param name="RecoveryCode">
|
||||
/// Present only immediately after enrolling. <b>Must be shown once and never stored.</b> 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.
|
||||
/// </param>
|
||||
/// <param name="Message">Something to show the user.</param>
|
||||
public sealed record ProvisionOutcome(
|
||||
ProvisionStatus Status,
|
||||
MeResponse Me,
|
||||
string? RecoveryCode,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Gets this machine from "signed in" to "has everything an offline unlock needs".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// After enrolling it re-reads <c>/me</c> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AccountProvisioner(
|
||||
IAccountApi api,
|
||||
IKeyBindingAuthorizer keyBinding,
|
||||
ClientCacheFactory caches,
|
||||
TimeProvider clock,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
{
|
||||
/// <summary>Reads the account and caches whatever an offline unlock will need.</summary>
|
||||
public async Task<ProvisionOutcome> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates this account's identity key and personal vault.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">The server this profile belongs to.</param>
|
||||
/// <param name="passphrase">The vault passphrase. Never transmitted or stored.</param>
|
||||
/// <param name="deviceName">Human-readable name for this machine, shown in the key statement.</param>
|
||||
/// <param name="vaultName">Display name for the personal vault.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <remarks>
|
||||
/// <b>No device key is registered.</b> 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.
|
||||
/// </remarks>
|
||||
public async Task<ProvisionOutcome> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Where this machine keeps its profile.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The choice of directory matters more than it looks.</b> 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 <em>local</em>, non-roaming location on every platform. On Windows
|
||||
/// that means <c>%LOCALAPPDATA%</c> and never <c>%APPDATA%</c>, which roams in a domain environment and
|
||||
/// would do exactly the wrong thing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="DataDirectory">The profile directory. Created on demand.</param>
|
||||
public sealed record ClientPaths(string DataDirectory)
|
||||
{
|
||||
private const string WindowsFolderName = "DodoSSH";
|
||||
private const string UnixFolderName = "dodossh";
|
||||
|
||||
/// <summary>The conventional location for this platform.</summary>
|
||||
public static ClientPaths Default { get; } = new(ResolveDataDirectory());
|
||||
|
||||
/// <summary>The encrypted local cache.</summary>
|
||||
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
|
||||
|
||||
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public void EnsureCreated() => Directory.CreateDirectory(DataDirectory);
|
||||
|
||||
/// <remarks>
|
||||
/// The platform branches are explicit rather than delegating to
|
||||
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/> everywhere. That enumeration does
|
||||
/// the right thing on Windows, but on macOS the runtime maps it to <c>~/.local/share</c> rather than
|
||||
/// to <c>~/Library/Application Support</c>, 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.
|
||||
/// <para>
|
||||
/// <c>XDG_DATA_HOME</c> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The client's session lifecycle: where a profile lives on disk, how a vault is unlocked, and how a
|
||||
fresh machine gets one in the first place.
|
||||
|
||||
This is the composition layer the application shell sits on, and it is deliberately Avalonia-free
|
||||
like every other Client.* project except App. That is what lets the part that actually matters —
|
||||
that an unlock works with no network, and that a wrong passphrase is a return value rather than a
|
||||
crash — be a fast unit test instead of something only reachable by clicking.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Client.Api/DodoSSH.Client.Api.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Session.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,262 @@
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for a token provider before anyone has signed in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal sealed class UnavailableAccessTokenProvider : IAccessTokenProvider
|
||||
{
|
||||
internal static UnavailableAccessTokenProvider Instance { get; } = new();
|
||||
|
||||
public ValueTask<string> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the bearer token fresh for the life of a connection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<string> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a signed-in server offers, as everything above the session layer needs it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public interface IVaultServer : IDisposable
|
||||
{
|
||||
/// <summary>The server this is connected to.</summary>
|
||||
Uri ServerUrl { get; }
|
||||
|
||||
/// <summary>Who am I, and publish my first key.</summary>
|
||||
IAccountApi Account { get; }
|
||||
|
||||
/// <summary>Pull and push.</summary>
|
||||
ISyncApi Sync { get; }
|
||||
|
||||
/// <summary>Obtains the identity provider's signature over a key statement.</summary>
|
||||
IKeyBindingAuthorizer KeyBinding { get; }
|
||||
|
||||
/// <summary>Sync tuning derived from what this server actually accepts.</summary>
|
||||
SyncOptions SyncOptions { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signed-in connection to one DodoSSH server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The onboarding story in one object: the user types a server URL, the client reads
|
||||
/// <c>/.well-known/dodossh-configuration</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A session outlives this. Losing the network invalidates the connection, not the vault — which is why
|
||||
/// syncing takes an <see cref="ISyncApi"/> per call rather than the session holding one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The server this is connected to.</summary>
|
||||
public Uri ServerUrl { get; }
|
||||
|
||||
/// <summary>What the server told us about itself and its identity provider.</summary>
|
||||
public DodoSshConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>Versions, features and limits.</summary>
|
||||
public MetaResponse Meta { get; }
|
||||
|
||||
/// <summary>The identity provider client, which is also the key-binding authorizer.</summary>
|
||||
public OidcClient Oidc { get; }
|
||||
|
||||
/// <summary>The authenticated API client.</summary>
|
||||
public DodoSshApiClient Api { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAccountApi Account => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISyncApi Sync => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IKeyBindingAuthorizer KeyBinding => Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// Sync tuning derived from what this server actually accepts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <inheritdoc cref="IVaultServer.SyncOptions" />
|
||||
public SyncOptions SyncOptions => new()
|
||||
{
|
||||
MaxOperationsPerPush = Math.Clamp(Meta.MaxOperationsPerPush, 1, 500),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Discovers the server, signs the user in through their browser, and returns the connection.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">The DodoSSH server's base URL — the only thing the user has to know.</param>
|
||||
/// <param name="browser">Opens the system browser. Never an embedded one; see RFC 8252.</param>
|
||||
/// <param name="clock">Time source, for token expiry.</param>
|
||||
/// <param name="cancellationToken">Cancels the wait for the browser.</param>
|
||||
public static async Task<ServerConnection> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
tokens.Dispose();
|
||||
http.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private static OidcClientOptions BuildOidcOptions(DodoSshConfiguration configuration) =>
|
||||
new()
|
||||
{
|
||||
Authority = configuration.Oidc.Authority,
|
||||
ClientId = configuration.Oidc.ClientId,
|
||||
Scopes = configuration.Oidc.Scopes,
|
||||
RequireHttpsMetadata = !configuration.Oidc.Authority.IsLoopback,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>Why an unlock did or did not produce a session.</summary>
|
||||
public enum UnlockStatus
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>The vault is open.</summary>
|
||||
Unlocked = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
NotEnrolled = 2,
|
||||
|
||||
/// <summary>
|
||||
/// The passphrase did not open the wrap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
WrongPassphrase = 3,
|
||||
|
||||
/// <summary>
|
||||
/// The identity opened but no vault grant did, so there is nothing readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
NoReadableVault = 4,
|
||||
|
||||
/// <summary>The cached KDF parameters are not something this build can use.</summary>
|
||||
UnsupportedKdf = 5,
|
||||
}
|
||||
|
||||
/// <summary>The result of an unlock attempt.</summary>
|
||||
/// <param name="Status">What happened.</param>
|
||||
/// <param name="Session">The open vault, present only when <paramref name="Status"/> is unlocked.</param>
|
||||
/// <param name="Message">Something to show the user. Never contains secret material.</param>
|
||||
public sealed record UnlockOutcome(UnlockStatus Status, VaultSession? Session, string Message)
|
||||
{
|
||||
/// <summary>Whether a session came back.</summary>
|
||||
public bool IsUnlocked => Status == UnlockStatus.Unlocked && Session is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the vault from what is already on this machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This path touches no network, deliberately and testably.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SessionOpener(
|
||||
ClientCacheFactory caches,
|
||||
TimeProvider clock,
|
||||
SyncOptions? options = null)
|
||||
{
|
||||
private readonly SyncOptions options = options ?? SyncOptions.Default;
|
||||
|
||||
/// <summary>Reads who this machine is enrolled as, without needing a passphrase.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public Task<StoredUnlockMaterial?> ReadProfileAsync(CancellationToken cancellationToken) =>
|
||||
new UnlockStore(caches, clock).ReadAsync(cancellationToken);
|
||||
|
||||
/// <summary>Attempts to open the vault.</summary>
|
||||
public async Task<UnlockOutcome> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<UnlockOutcome> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A conflict, decoded and ready to show.</summary>
|
||||
/// <param name="Id">The conflict record, so it can be acknowledged.</param>
|
||||
/// <param name="EntityId">The item it happened to.</param>
|
||||
/// <param name="Kind">What happened.</param>
|
||||
/// <param name="Summary">One line for a person.</param>
|
||||
/// <param name="Fields">
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <param name="DetectedAt">When it was noticed.</param>
|
||||
public sealed record ConflictNotice(
|
||||
Guid Id,
|
||||
Guid EntityId,
|
||||
ConflictKind Kind,
|
||||
string Summary,
|
||||
IReadOnlyList<ConflictDetailEntry> Fields,
|
||||
DateTimeOffset DetectedAt);
|
||||
|
||||
/// <summary>
|
||||
/// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The sync engine is <em>not</em> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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<StoredVault> 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);
|
||||
}
|
||||
|
||||
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
|
||||
public StoredUnlockMaterial Profile { get; }
|
||||
|
||||
/// <summary>Every vault this user can reach, readable or not.</summary>
|
||||
public IReadOnlyList<StoredVault> Vaults { get; }
|
||||
|
||||
/// <summary>The vault the interface is showing. The personal one, for now.</summary>
|
||||
public Guid ActiveVaultId { get; }
|
||||
|
||||
/// <summary>Hosts, decrypted, with unpushed local changes laid over them.</summary>
|
||||
public HostRepository Hosts { get; }
|
||||
|
||||
/// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
|
||||
public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened;
|
||||
|
||||
internal ItemStore Items { get; }
|
||||
|
||||
internal OutboxStore Outbox { get; }
|
||||
|
||||
internal SyncStateStore SyncState { get; }
|
||||
|
||||
internal ConflictStore Conflicts { get; }
|
||||
|
||||
internal VaultStore Vault { get; }
|
||||
|
||||
/// <summary>Runs one synchronisation pass over the active vault.</summary>
|
||||
/// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task<SyncReport> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the conflicts a person still needs to see.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<ConflictNotice>> ReadConflictsAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
var stored = await Conflicts
|
||||
.ListAsync(ActiveVaultId, includeAcknowledged: false, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return [.. stored.Select(Describe)];
|
||||
}
|
||||
|
||||
/// <summary>Marks a conflict as seen, keeping the discarded value retrievable.</summary>
|
||||
public Task<bool> AcknowledgeConflictAsync(Guid conflictId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>How many local changes are waiting to be pushed.</summary>
|
||||
public async Task<int> PendingChangeCountAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false);
|
||||
return pending.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ namespace DodoSSH.Client.Storage;
|
||||
public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>, IDisposable
|
||||
{
|
||||
private readonly DbContextOptions<ClientCacheContext> options;
|
||||
private readonly string connectionString;
|
||||
|
||||
/// <remarks>
|
||||
/// 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<ClientCacheContext>,
|
||||
|
||||
private ClientCacheFactory(string connectionString, SqliteConnection? keepAlive)
|
||||
{
|
||||
this.connectionString = connectionString;
|
||||
this.keepAlive = keepAlive;
|
||||
|
||||
options = new DbContextOptionsBuilder<ClientCacheContext>()
|
||||
@@ -41,7 +43,22 @@ public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>,
|
||||
.Options;
|
||||
}
|
||||
|
||||
/// <summary>Opens, or creates, a cache file.</summary>
|
||||
/// <summary>
|
||||
/// Opens, or creates, a cache file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The parent directory must exist; SQLite will not create one. <c>ClientPaths.EnsureCreated</c> is
|
||||
/// what does that, and it runs before this in the application's startup.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The database ends up in WAL mode</b>, 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 <c>cache.db</c> is wrong.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="databasePath">Full path to the SQLite file.</param>
|
||||
public static ClientCacheFactory ForFile(string databasePath)
|
||||
{
|
||||
@@ -50,8 +67,9 @@ public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>,
|
||||
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<ClientCacheContext>,
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -154,14 +154,18 @@ public sealed record ConflictDetailEntry(
|
||||
public sealed record ConflictDetail(string Summary, IReadOnlyList<ConflictDetailEntry> Fields);
|
||||
|
||||
/// <summary>
|
||||
/// Serialises what a merge discarded, for the conflict log.
|
||||
/// Reads and writes what a merge discarded, for the conflict log.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bytes crossing into <c>ConflictStore</c> 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class ConflictDetailCodec
|
||||
public static class ConflictDetails
|
||||
{
|
||||
internal static byte[] Encode(string summary, IReadOnlyList<HostFieldConflict> conflicts) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(
|
||||
@@ -178,7 +182,11 @@ internal static class ConflictDetailCodec
|
||||
internal static byte[] Encode(string summary) => Encode(summary, []);
|
||||
|
||||
/// <summary>Reads a detail back, for display.</summary>
|
||||
internal static ConflictDetail? TryDecode(ReadOnlySpan<byte> utf8)
|
||||
/// <returns>
|
||||
/// The detail, or <see langword="null"/> 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.
|
||||
/// </returns>
|
||||
public static ConflictDetail? TryRead(ReadOnlySpan<byte> utf8)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -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<DodoSshApiException>(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<ArgumentException>(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()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The shell's state machine, without Avalonia.
|
||||
|
||||
The view models are plain CommunityToolkit.Mvvm objects, so the whole path a user walks — sign in,
|
||||
enroll, keep the recovery code, unlock, add a host — runs here as ordinary code against an in-memory
|
||||
server and a real SQLite cache. No headless renderer, no identity provider, no clicking.
|
||||
|
||||
What this deliberately does not cover is whether the XAML binds to the right names. That needs a
|
||||
rendered visual tree; Avalonia.Headless is the tool for it and is its own piece of work.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A signed-in server, without the signing in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stands in for a <c>ServerConnection</c> 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 <c>/me</c>
|
||||
/// 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 <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces
|
||||
/// version checks.
|
||||
/// </remarks>
|
||||
internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
|
||||
{
|
||||
private readonly List<SyncChange> log = [];
|
||||
private readonly Dictionary<Guid, SyncChange> 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);
|
||||
|
||||
/// <summary>When set, the next sign-in throws — how an unreachable server is exercised.</summary>
|
||||
internal Exception? SignInFailure { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Uri ServerUrl { get; } = new("https://dodossh.example");
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAccountApi Account => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISyncApi Sync => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IKeyBindingAuthorizer KeyBinding => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncOptions SyncOptions => SyncOptions.Default;
|
||||
|
||||
/// <inheritdoc />
|
||||
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 ----
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> AuthorizeKeyBindingAsync(string bindingNonce, CancellationToken cancellationToken) =>
|
||||
Task.FromResult("stub-id-token");
|
||||
|
||||
// ---- Account ----
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MeResponse> 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]));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EnrollmentResponse> 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 ----
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SyncPullResponse> 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));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SyncPushResponse> SyncPushAsync(
|
||||
Guid vaultId,
|
||||
SyncPushRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PushCount++;
|
||||
|
||||
var results = new List<SyncPushResult>(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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The whole path a user walks: sign in, enroll, keep the recovery code, unlock, add a host, sync.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public sealed class ShellFlowTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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!;
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string, TerminalAsset>(StringComparer.Ordinal)
|
||||
{
|
||||
["/terminal"] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
|
||||
}),
|
||||
new SshNetConnectionFactory(knownHosts),
|
||||
TimeProvider.System);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
SignInAsync,
|
||||
TimeProvider.System,
|
||||
CheapProfile);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<IVaultServer> SignInAsync(Uri serverUrl, CancellationToken cancellationToken) =>
|
||||
server.SignInFailure is { } failure
|
||||
? Task.FromException<IVaultServer>(failure)
|
||||
: Task.FromResult<IVaultServer>(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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace DodoSSH.Client.Session.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Where the profile goes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The session lifecycle, with no HTTP and no UI. What matters here is that an unlock works with
|
||||
nothing but the passphrase and a cache file — which is the property a user discovers on a plane, and
|
||||
the last place you want to find out by clicking.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,170 @@
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Session.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// An in-memory account server: just-in-time provisioning, enrollment, and <c>/me</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>/me</c> after enrolling rather than
|
||||
/// caching what it believes it sent, and a stub that echoed the request would make that check vacuous.
|
||||
/// <para>
|
||||
/// 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 <c>DodoSSH.Api.Tests</c>; repeating them here would test
|
||||
/// this file rather than the client.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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";
|
||||
|
||||
/// <summary>The enrollment request as received, so a test can assert what was actually sent.</summary>
|
||||
internal EnrollmentRequest? LastEnrollment { get; private set; }
|
||||
|
||||
internal int EnrollmentCount { get; private set; }
|
||||
|
||||
internal int MeCount { get; private set; }
|
||||
|
||||
/// <summary>Whether an identity key has been published.</summary>
|
||||
internal bool IsEnrolled => statement is not null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MeResponse> 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]));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EnrollmentResponse> 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));
|
||||
}
|
||||
|
||||
/// <summary>Drops the vault grant, as a rekey does until it is re-issued.</summary>
|
||||
internal void RevokeVaultGrant() =>
|
||||
personalVault = personalVault is null
|
||||
? null
|
||||
: personalVault with { WrappedVaultKey = null, RekeyRequired = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the identity provider's signature over a key statement.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>ClientEnrollmentTests</c>; here it only needs to exist.
|
||||
/// </remarks>
|
||||
internal sealed class StubKeyBinding : IKeyBindingAuthorizer
|
||||
{
|
||||
internal string? RequestedNonce { get; private set; }
|
||||
|
||||
public Task<string> AuthorizeKeyBindingAsync(
|
||||
string bindingNonce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestedNonce = bindingNonce;
|
||||
return Task.FromResult("stub-id-token");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A server with no changes in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enough to prove the session composes a working sync engine. The interesting sync behaviour lives in
|
||||
/// <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces version checks; duplicating that here
|
||||
/// would be a third implementation of the same decision table.
|
||||
/// </remarks>
|
||||
internal sealed class EmptySyncApi : ISyncApi
|
||||
{
|
||||
internal int PushCount { get; private set; }
|
||||
|
||||
public Task<SyncPullResponse> 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<SyncPushResponse> 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Session.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Enrolling once, then unlocking with nothing but a passphrase and a file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="SessionOpener"/> that has never been given a
|
||||
/// transport and could not reach one if it wanted to.
|
||||
/// </remarks>
|
||||
public sealed class SessionLifecycleTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "correct horse battery staple";
|
||||
private const string ServerUrl = "https://dodossh.example";
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
caches = ClientCacheFactory.ForMemory($"session-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<ObjectDisposedException>(
|
||||
async () => await session.ReadConflictsAsync(Token));
|
||||
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
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<VaultSession> 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",
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Storage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The cache as it is actually deployed: a file on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every other suite here uses an in-memory database because it is faster and isolated. That leaves the
|
||||
/// production path — <see cref="ClientCacheFactory.ForFile"/>, 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.
|
||||
/// </remarks>
|
||||
public sealed class FileBackedCacheTests : IDisposable
|
||||
{
|
||||
private readonly string directory =
|
||||
Path.Combine(Path.GetTempPath(), $"dodossh-cache-{Guid.CreateVersion7():N}");
|
||||
|
||||
/// <inheritdoc />
|
||||
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<Microsoft.Data.Sqlite.SqliteException>(
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user