Public Access
Stay signed in, come back online by itself, and let a machine be given up
Three things a machine that has been set up could not do. Unlock now takes Enter, which is the gesture everybody makes after typing a password and which did nothing until they found the button. Signing in survives a relaunch. The refresh token is kept in the local cache, sealed under the vault's own cache key, so a later launch resumes the session through the refresh grant with no browser and nobody present — and because it is sealed under that key, only an unlocked vault can resume it. A locked client therefore cannot reach the server at all, which is a consequence worth stating rather than working around; docs/crypto.md §3.2 records it. Every sync pass asks the shell for a connection rather than reading one captured at unlock, so a laptop that unlocked on a train is online within a minute of finding a network, with nothing pressed. Unlocking itself still never waits on a socket. Signing out empties this machine: the profile, the cached items, the outbox and this machine's device key, with the account's row withdrawn when the server can be reached. It asks first and says what it costs — the outbox count when the vault is open, an admission that it cannot be counted when it is not, and the shells that keep running either way. The vault is on the server and is untouched, which is what makes the same button the only honest answer to a forgotten passphrase, so it is on the unlock screen as well as in preferences. It cannot end the session at the identity provider, and says so. Two defects surfaced on the way. The synchronisation pass that runs when the vault opens never ran at all: the loop is started from inside the unlock command, so the busy flag it yields to was raised by that command — the first sync was a minute late on every launch. And signing in from preferences while unlocked threw an unlock screen over an open vault whose keys were still in memory. The unlock card and the new confirmation live in their own controls because MainWindow cannot be laid out headless, so markup left inside it is markup no test can measure; both are now measured at the window's minimum size in the shapes that grow. What is still unverified is the composed window itself.
This commit is contained in:
@@ -145,6 +145,21 @@ the next sync. If a server is legitimately rebuilt and offers a new key, the con
|
||||
with no way to continue from the warning — edit the host and choose **Forget host key**, which is deliberately
|
||||
somewhere you have to go on purpose.
|
||||
|
||||
**Signing in once is enough.** The refresh token is kept in the local cache, sealed under the vault's own
|
||||
key, so a later launch resumes the session itself and no browser opens — and because it is sealed under that
|
||||
key, resuming can only happen *after* the vault is unlocked. A machine that unlocks with no network keeps
|
||||
trying: every synchronisation pass asks for a connection, so a laptop opened on a train is online again
|
||||
within a minute of finding a network, with nothing pressed. Unlock takes **Enter** in the passphrase box,
|
||||
and nothing about unlocking ever waits on the network.
|
||||
|
||||
**Signing out** is under Preferences → *Account*, and again on the unlock screen, where it is the only
|
||||
answer to a forgotten passphrase — nothing can recover one. It asks first, and says what it costs: it
|
||||
empties this machine's cache (the profile, the cached items, and anything still queued to be sent) and
|
||||
withdraws this machine's device key from the account. The vault itself is on the server and is untouched, so
|
||||
signing in again brings it all back; the count in the confirmation is the one thing that exists nowhere
|
||||
else. Your session at the identity provider is *not* ended — DodoSSH has no way to end it — so on a machine
|
||||
that is not yours, sign out there too.
|
||||
|
||||
Two of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: password
|
||||
authentication asks for the password every time, because nothing in the interface can create a vault
|
||||
credential yet (they do sync — there is just no editor for one); and unlock asks for the passphrase on every
|
||||
|
||||
@@ -193,6 +193,16 @@ moment to discard it.
|
||||
The label is versioned, so a client holding a v1 cache fails to open it and re-pulls rather than
|
||||
decrypting to nonsense. That is the whole reason for bumping rather than reusing the label.
|
||||
|
||||
**What it seals, and the one entry that is not vault content.** Three kinds of record: the plaintext
|
||||
columns the server needs, the values a merge overrode, and — added 2026-07-31 — the OIDC **refresh
|
||||
token** this machine may resume its sign-in with, bound as `LocalCache(User, userId)`. The third is
|
||||
different in kind from the other two: it is a credential for the *account*, not for the vault, and
|
||||
sealing it here is a deliberate choice about what a stolen cache file is worth. A refresh token kept in
|
||||
the clear beside the ciphertext would let a copied profile reach the server as its owner without the
|
||||
passphrase ever being guessed; under this key it can only be read by a process that has already opened
|
||||
the vault. The cost is stated rather than worked around: **a locked client cannot reach the server at
|
||||
all**, because the token it would present is behind the same lock as everything else.
|
||||
|
||||
### Why the bundle is wrapped many ways
|
||||
|
||||
This is the load-bearing structural choice. Because every wrap protects the *same* bundle:
|
||||
|
||||
@@ -153,8 +153,14 @@ Nothing on this screen exists. It is in the nav rail and reaches a screen that s
|
||||
|
||||
## Preferences
|
||||
|
||||
The screen ships with what is real — this machine's device key, locking, and syncing — and lists the rest
|
||||
as absent rather than omitting it silently.
|
||||
The screen ships with what is real — this machine's device key, locking, syncing, and signing out — and
|
||||
lists the rest as absent rather than omitting it silently.
|
||||
|
||||
> **Signing out is not on the design and is here anyway.** The design has no way to leave a machine, and
|
||||
> without one there is no way to hand a laptop on, to enrol a second account, or to get past a forgotten
|
||||
> passphrase — which is unrecoverable by construction, so the unlock screen would otherwise be a dead end.
|
||||
> It empties the local cache and withdraws the device key from the account; it cannot end the session at the
|
||||
> identity provider, and says so.
|
||||
|
||||
| Design element | Layer | What it would take |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -81,7 +81,14 @@ internal sealed partial class DodoSshApp : Application
|
||||
async (url, cancellationToken) => await ServerConnection
|
||||
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
TimeProvider.System);
|
||||
TimeProvider.System,
|
||||
passphraseProfile: null,
|
||||
|
||||
// The other half of signing in: a refresh grant, no browser, and nobody present. It is what
|
||||
// makes a launch after the first one arrive online rather than merely enrolled.
|
||||
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
|
||||
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
|
||||
@@ -115,10 +115,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private readonly IDeviceKeyStore deviceKeys;
|
||||
|
||||
private readonly SignInHandler signIn;
|
||||
|
||||
/// <remarks>
|
||||
/// Optional, and null is not merely "not configured": a shell with no resume handler is one that can
|
||||
/// only be online because somebody signed in during this run, which is what every test that asserts
|
||||
/// offline behaviour relies on.
|
||||
/// </remarks>
|
||||
private readonly ResumeHandler? resume;
|
||||
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Argon2Profile? passphraseProfile;
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
private string? rememberedToken;
|
||||
|
||||
/// <summary>Guards against two resume attempts overlapping.</summary>
|
||||
/// <remarks>
|
||||
/// A plain flag rather than a semaphore because every caller is on the UI thread — the sync loop and
|
||||
/// the Sync button — and what has to be prevented is a second attempt starting while the first is
|
||||
/// waiting on a token endpoint, not a data race.
|
||||
/// </remarks>
|
||||
private bool resuming;
|
||||
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -132,6 +152,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Re-establishes a connection from a remembered sign-in, without a browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A delegate for the same reason <see cref="SignInHandler"/> is one: a real resume needs discovery
|
||||
/// and a token endpoint, and making that the only way to reach this state machine would put the whole
|
||||
/// "comes back online by itself" behaviour out of reach of a test.
|
||||
/// </remarks>
|
||||
internal delegate Task<IVaultServer> ResumeHandler(
|
||||
Uri serverUrl,
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
internal MainWindowViewModel(
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
@@ -140,7 +173,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
IDeviceKeyStore deviceKeys,
|
||||
SignInHandler signIn,
|
||||
TimeProvider clock,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
Argon2Profile? passphraseProfile = null,
|
||||
ResumeHandler? resume = null)
|
||||
{
|
||||
this.paths = paths;
|
||||
this.caches = caches;
|
||||
@@ -148,6 +182,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.knownHosts = knownHosts;
|
||||
this.deviceKeys = deviceKeys;
|
||||
this.signIn = signIn;
|
||||
this.resume = resume;
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
|
||||
@@ -262,6 +297,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
internal bool IsLocked => State == ShellState.Locked;
|
||||
|
||||
/// <summary>Whether the unlock card itself is showing, rather than the confirmation over it.</summary>
|
||||
/// <remarks>
|
||||
/// Its own property because the markup cannot express <c>IsLocked && !IsConfirmingSignOut</c>,
|
||||
/// and the two cards genuinely swap rather than stack: the unlock card is already near the height the
|
||||
/// window guarantees at its minimum size, so putting a second question underneath it would push
|
||||
/// buttons off a screen with nothing to scroll.
|
||||
/// </remarks>
|
||||
internal bool IsAskingForThePassphrase => IsLocked && !IsConfirmingSignOut;
|
||||
|
||||
internal bool IsUnlocked => State == ShellState.Unlocked;
|
||||
|
||||
/// <summary>Whether a connection to the server is currently held.</summary>
|
||||
@@ -642,9 +686,23 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
|
||||
StatusMessage = outcome.Message;
|
||||
|
||||
State = outcome.Status == ProvisionStatus.EnrollmentRequired
|
||||
? ShellState.NeedsEnrollment
|
||||
: ShellState.Locked;
|
||||
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
|
||||
{
|
||||
State = ShellState.NeedsEnrollment;
|
||||
return;
|
||||
}
|
||||
|
||||
// An unlocked vault stays unlocked. This command is reachable from the preferences screen
|
||||
// of a running application — it is how somebody whose sign-in expired gets back online —
|
||||
// and moving the state machine to Locked there would throw an unlock screen over an open
|
||||
// vault whose keys are still in memory, which is neither locked nor honest.
|
||||
if (IsUnlocked)
|
||||
{
|
||||
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
State = ShellState.Locked;
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -884,7 +942,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
throw;
|
||||
}
|
||||
|
||||
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection);
|
||||
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection, ReconnectAsync);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||
@@ -908,9 +966,190 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// After the first load, so the list is on screen before anything talks to a server. The loop is
|
||||
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
|
||||
// observable collections single-threaded.
|
||||
//
|
||||
// Its first pass is also what brings this machine online: the pass asks ReconnectAsync for a
|
||||
// server, and that is where a remembered sign-in is resumed. Nothing here has to know whether
|
||||
// this unlock followed a sign-in or a cold launch on a train.
|
||||
//
|
||||
// Deliberately not awaited here, and not done before this point either. Resuming is a discovery
|
||||
// call and a token exchange — a network round trip, and on an unreachable network a slow one —
|
||||
// and unlocking must never wait on one. Everything the unlock screen promises about working
|
||||
// offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
|
||||
// the titlebar says OFFLINE until the round trip this starts has an answer.
|
||||
Vault.StartAutoSync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Handed to the vault, which asks once per synchronisation pass. That cadence is the whole design:
|
||||
/// there is no connectivity monitor and no reconnect backoff, because a pass a minute already is one,
|
||||
/// and a machine that comes back from a closed lid is online again within a minute of having a
|
||||
/// network — with nothing pressed and no browser opened.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Resuming needs an unlocked vault, and that is deliberate rather than incidental.</b> The
|
||||
/// remembered refresh token is sealed under the vault's own cache key, so this can only succeed after
|
||||
/// somebody has opened the vault — a stolen laptop yields a cache file that cannot reach the account
|
||||
/// any more than it can read the hosts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every failure returns null and stays quiet, with one exception: a provider that <em>refuses</em> the
|
||||
/// token is not a transient condition and will refuse it again once a minute forever, so that one is
|
||||
/// said out loud and the token is dropped.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<IVaultServer?> ReconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection is { } held)
|
||||
{
|
||||
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
return held;
|
||||
}
|
||||
|
||||
if (resume is not { } handler || resuming || Vault is not { } vault)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
resuming = true;
|
||||
|
||||
try
|
||||
{
|
||||
return await ResumeAsync(vault, handler, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
resuming = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Split from <see cref="ReconnectAsync"/> only so the guard, the flag and the attempt are three short
|
||||
/// things rather than one long one. Everything about why this behaves as it does is up there.
|
||||
/// </remarks>
|
||||
private async Task<IVaultServer?> ResumeAsync(
|
||||
VaultViewModel vault,
|
||||
ResumeHandler handler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = await vault.Session
|
||||
.ReadRememberedSignInAsync(cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (token is null
|
||||
|| !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resumed = await handler(server, token, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
connection = resumed;
|
||||
rememberedToken = token;
|
||||
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
RaiseSyncState();
|
||||
|
||||
// The refresh that just happened may have rotated the token, and the rotated one is the only
|
||||
// one the next launch can use.
|
||||
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return resumed;
|
||||
}
|
||||
catch (OidcException exception)
|
||||
{
|
||||
// The provider answered and said no: the session was revoked, or the token was rotated and
|
||||
// this machine kept the old one. Retrying costs a round trip a minute and can only ever get
|
||||
// the same answer, so the token goes and the user is told the one thing that fixes it.
|
||||
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
||||
+ "Sign in again from Preferences to start syncing.");
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Everything else is a machine with no network, a server that is down, or a vault that was
|
||||
// locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
|
||||
// titlebar already says OFFLINE; a socket error once a minute would say nothing more.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the connection's current refresh token into the vault, if it has changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called on every pass rather than driven by an event, because providers rotate the token inside a
|
||||
/// refresh that happens on whatever thread an API call was made from — and a value read once a minute
|
||||
/// is current enough for something only a relaunch reads. A failure here costs one browser sign-in on
|
||||
/// the next launch and nothing else, which is not worth interrupting anybody over.
|
||||
/// </remarks>
|
||||
private async Task RememberSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection?.RefreshToken is not { } token
|
||||
|| Vault is not { } vault
|
||||
|| string.Equals(token, rememberedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await vault.Session.RememberSignInAsync(token, cancellationToken).ConfigureAwait(true);
|
||||
rememberedToken = token;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Left unremembered. The application is signed in for this run either way.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
|
||||
private async Task ForgetSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
rememberedToken = null;
|
||||
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// A cache that will not take the deletion is one the next launch will fail to resume from and
|
||||
// then delete itself. Nothing here is worth a message.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says something wherever the user is looking.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The shell's own message is on the setup and unlock cards, and the status bar shows the vault's — so
|
||||
/// a message about the connection, which is the shell's business but only interesting while somebody
|
||||
/// is using an open vault, has to go to both or it is invisible half the time.
|
||||
/// </remarks>
|
||||
private void Announce(string message)
|
||||
{
|
||||
StatusMessage = message;
|
||||
|
||||
if (Vault is { } vault)
|
||||
{
|
||||
vault.Status = message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the vault and forgets every key it held. Open shells keep running.
|
||||
/// </summary>
|
||||
@@ -955,10 +1194,170 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
// A confirmation armed on the preferences screen must not survive onto the unlock screen, where
|
||||
// the same card is offered with a warning it can no longer count.
|
||||
IsConfirmingSignOut = false;
|
||||
|
||||
State = ShellState.Locked;
|
||||
StatusMessage = "Locked.";
|
||||
}
|
||||
|
||||
// ---- Signing out ----
|
||||
|
||||
/// <summary>Whether the sign-out confirmation is showing.</summary>
|
||||
/// <remarks>
|
||||
/// A state rather than a dialog, for the same reason the recovery code is a screen: this is the one
|
||||
/// action in the application that destroys something a user cannot get back from here — an unpushed
|
||||
/// change — and it has to be able to say what is about to go before it goes.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isConfirmingSignOut;
|
||||
|
||||
/// <summary>
|
||||
/// What signing out costs, on this machine, right now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The count is the part worth having. Everything else in the vault is on the server and comes back
|
||||
/// with the next sign-in; an operation still in the outbox exists nowhere else in the world, and
|
||||
/// "your changes will be lost" without a number leaves somebody guessing whether it means theirs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A locked vault cannot be counted — the outbox is sealed under the key the vault holds — so it gets
|
||||
/// the honest form of the same warning rather than a zero it has not earned.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string SignOutWarning => (IsUnlocked, Vault?.PendingChanges ?? 0) switch
|
||||
{
|
||||
(false, _) =>
|
||||
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
|
||||
+ "counted from here, because the vault is locked.",
|
||||
(true, 0) =>
|
||||
"Everything this machine has changed has reached the server, so nothing will be lost.",
|
||||
(true, 1) =>
|
||||
"1 change has not reached the server yet and will be lost. Sync first to keep it.",
|
||||
(true, var pending) =>
|
||||
$"{pending} changes have not reached the server yet and will be lost. Sync first to keep them.",
|
||||
};
|
||||
|
||||
/// <summary>Asks whether the user means it.</summary>
|
||||
[RelayCommand]
|
||||
private void SignOut()
|
||||
{
|
||||
// Taken now so the card can disclose it, on the same reasoning as the lock screen's: signing out
|
||||
// does not close a shell any more than locking does, and a screen that sends somebody back to
|
||||
// "connect to your server" while their upgrade is still running should say so.
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
OnPropertyChanged(nameof(SignOutWarning));
|
||||
|
||||
IsConfirmingSignOut = true;
|
||||
}
|
||||
|
||||
/// <summary>Thinks better of it.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelSignOut() => IsConfirmingSignOut = false;
|
||||
|
||||
/// <summary>
|
||||
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>What this does and does not destroy.</b> It empties the local cache — the profile, the wrapped
|
||||
/// bundle, the item mirror, the outbox and the conflict log — and forgets this machine's device key
|
||||
/// here and on the account. The vault itself is on the server and is untouched, which is what makes
|
||||
/// this safe to offer beside a passphrase box: somebody who has forgotten their passphrase can reset
|
||||
/// this machine and sign in again, and the only thing they lose is what this machine had not yet sent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ordered so that a failure cannot leave a half-signed-out machine.</b> The device is withdrawn
|
||||
/// while there is still a session and a connection to withdraw it through; the vault is closed before
|
||||
/// the cache under it is emptied; and the cache is emptied last, because it is the step that makes
|
||||
/// this machine unenrolled and everything before it is a courtesy that a wiped profile makes moot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It does not end the session at the identity provider — there is no back channel to it from here,
|
||||
/// and pretending otherwise would be the sort of claim this project writes down instead of implying.
|
||||
/// The refresh token this machine held is dropped and never used again; the provider's own session
|
||||
/// outlives it, which is what the preferences screen says out loud.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmSignOutAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await RunAsync(
|
||||
"Signing out…",
|
||||
async () =>
|
||||
{
|
||||
IsConfirmingSignOut = false;
|
||||
|
||||
await WithdrawThisMachineAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// As Lock does, and before the session it reads from goes.
|
||||
knownHosts.Close();
|
||||
|
||||
if (Vault is { } open)
|
||||
{
|
||||
Vault = null;
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
connection?.Dispose();
|
||||
connection = null;
|
||||
rememberedToken = null;
|
||||
|
||||
await caches.ResetAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
AccountName = null;
|
||||
Passphrase = string.Empty;
|
||||
ConfirmPassphrase = string.Empty;
|
||||
RecoveryCode = null;
|
||||
RecoveryCodeWrittenDown = false;
|
||||
CanUnlockWithDevice = false;
|
||||
CanRegisterDevice = false;
|
||||
CanForgetDevice = false;
|
||||
|
||||
State = ShellState.NeedsServer;
|
||||
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
RaiseSyncState();
|
||||
|
||||
StatusMessage = "Signed out. This machine's copy of the vault has been deleted; the vault "
|
||||
+ "itself is untouched. Sign in to set this machine up again.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Best effort, and swallowed on purpose. Withdrawing the device is the tidy half of signing out — the
|
||||
/// half that stops the account listing a machine whose key is about to be deleted — and a server that
|
||||
/// cannot be reached, or a keystore that declines, must not be able to strand somebody on a screen
|
||||
/// they asked to leave. The half that decides whether this machine can let itself in happens anyway,
|
||||
/// because the profile holding the wrap is emptied a moment later.
|
||||
/// </remarks>
|
||||
private async Task WithdrawThisMachineAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Vault is { } vault)
|
||||
{
|
||||
await vault.Session
|
||||
.ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Nothing to report: the wipe below is what signing out actually is.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -1127,6 +1526,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
OnPropertyChanged(nameof(IsFullySynced));
|
||||
OnPropertyChanged(nameof(SyncLabel));
|
||||
|
||||
// The same fact from a third direction: what signing out would cost is the outbox depth, and a
|
||||
// confirmation card left showing a count from before the last pass would be quoting a number that
|
||||
// has since been sent.
|
||||
OnPropertyChanged(nameof(SignOutWarning));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -1233,8 +1637,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsNeedingEnrollment));
|
||||
OnPropertyChanged(nameof(IsShowingRecoveryCode));
|
||||
OnPropertyChanged(nameof(IsLocked));
|
||||
OnPropertyChanged(nameof(IsAskingForThePassphrase));
|
||||
OnPropertyChanged(nameof(IsUnlocked));
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
OnPropertyChanged(nameof(SignOutWarning));
|
||||
RaiseSyncState();
|
||||
|
||||
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
|
||||
@@ -1262,6 +1668,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
partial void OnIsSearchingChanged(bool value) => OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
|
||||
/// <remarks>
|
||||
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
|
||||
/// <see cref="IsAskingForThePassphrase"/>.
|
||||
/// </remarks>
|
||||
partial void OnIsConfirmingSignOutChanged(bool value) =>
|
||||
OnPropertyChanged(nameof(IsAskingForThePassphrase));
|
||||
|
||||
partial void OnCanRegisterDeviceChanged(bool value) =>
|
||||
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
|
||||
|
||||
|
||||
@@ -422,6 +422,24 @@ internal sealed record VaultItemRowViewModel(
|
||||
internal bool HasBadge => Badge.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets this machine online, if it can be.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Supplied by the shell, which owns the connection and the remembered sign-in behind it. It is asked
|
||||
/// once per synchronisation pass rather than once per vault, and that is what makes coming back from a
|
||||
/// closed lid automatic: a laptop that unlocks on a train has no connection and gets one within a minute
|
||||
/// of reaching a network, with nothing pressed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Returns null for every reason a machine may be offline — no remembered sign-in, no network, a token
|
||||
/// the provider has stopped accepting — because the vault's answer to all of them is the same: work
|
||||
/// locally and queue.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal delegate Task<IVaultServer?> ServerReconnectHandler(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
|
||||
/// </summary>
|
||||
@@ -438,6 +456,12 @@ internal sealed record VaultItemRowViewModel(
|
||||
/// A background pass is deliberately quieter than the button: see <see cref="AutoSyncAsync" />.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Being offline is a state a pass tries to leave, not one it gives up on.</b> Every pass asks the
|
||||
/// shell for a connection rather than reading one it was handed at unlock — see
|
||||
/// <see cref="ServerReconnectHandler" /> — so a machine that unlocked with no network comes online by
|
||||
/// itself once it has one, and a sign-in survives a restart without a browser opening.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Everything a connection needs is in the vault.</b> Keys, passwords and host key trust are all synced
|
||||
/// items, so each is stored once and available on every machine — approving a fingerprint here approves it on
|
||||
/// every device and survives a restart. A typed password is what is left when a host is bound to nothing, and
|
||||
@@ -456,7 +480,8 @@ internal sealed partial class VaultViewModel(
|
||||
VaultSession session,
|
||||
TerminalWorkspace workspace,
|
||||
VaultKnownHostStore knownHosts,
|
||||
Func<IVaultServer?> connection) : ObservableObject, IAsyncDisposable
|
||||
Func<IVaultServer?> connection,
|
||||
ServerReconnectHandler? reconnect = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <remarks>
|
||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
||||
@@ -1138,21 +1163,27 @@ internal sealed partial class VaultViewModel(
|
||||
private static string Endpoint(string host, int port) =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{host}:{port}");
|
||||
|
||||
/// <summary>Runs a synchronisation pass, if there is a server to talk to.</summary>
|
||||
/// <summary>Runs a synchronisation pass, if this machine can reach a server.</summary>
|
||||
/// <remarks>
|
||||
/// The offline branch is inside <see cref="RunAsync" /> rather than in front of it, because getting
|
||||
/// online is now part of what this button does: resuming a remembered sign-in is a network round trip
|
||||
/// and belongs under the same busy flag as the pass it leads to.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task SyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
LastSyncFailed = true;
|
||||
Status = "Offline. Changes are queued and will be sent after you sign in.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Synchronising…",
|
||||
async () =>
|
||||
{
|
||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||
{
|
||||
LastSyncFailed = true;
|
||||
Status = "Offline. Changes are queued and will be sent as soon as this machine "
|
||||
+ "can reach the server again.";
|
||||
return;
|
||||
}
|
||||
|
||||
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Null means a background pass held the gate. Saying so beats reporting a sync that this
|
||||
@@ -1163,6 +1194,18 @@ internal sealed partial class VaultViewModel(
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a server to sync against, getting this machine online if it is not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The handler is asked even when a connection is already held, which looks redundant and is not: the
|
||||
/// shell is the thing that persists the refresh token so a later launch can resume, and identity
|
||||
/// providers rotate that token on every refresh. Asking once per pass is what keeps the remembered
|
||||
/// sign-in current without an event and without this view model knowing what a token is.
|
||||
/// </remarks>
|
||||
private Task<IVaultServer?> ResolveServerAsync(CancellationToken cancellationToken) =>
|
||||
reconnect is null ? Task.FromResult(connection()) : reconnect(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Starts syncing in the background until the vault is disposed.
|
||||
/// </summary>
|
||||
@@ -1199,13 +1242,38 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal async Task AutoSyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsBusy || connection() is not { } server)
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One background synchronisation pass, run whether or not a command is in flight.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The same quiet pass as <see cref="AutoSyncAsync" /> without the one thing that made it useless at
|
||||
/// the moment it matters most. The loop is started from inside the unlock command, so the busy flag a
|
||||
/// timed pass yields to is raised by the very command that opened the vault — and the pass on open
|
||||
/// therefore never ran, silently, putting the first synchronisation a full minute after unlock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Yielding is right for every later pass, because by then a busy flag means a person is doing
|
||||
/// something. It is wrong for this one, because the thing it would be yielding to is the unlock.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task SyncOnOpenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
if (report is not null && (report.Pulled > 0 || report.Pushed > 0 || report.NeedsAttention))
|
||||
@@ -1277,7 +1345,14 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
// A pass on open, before the first tick. A vault edited on another machine should be current by
|
||||
// the time the user has finished reading the list, not a minute afterwards.
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
//
|
||||
// Deliberately not through AutoSyncAsync, and this is not a shortcut. This loop is started from
|
||||
// inside the unlock command, so the busy flag that pass yields to is raised by the very command
|
||||
// that opened the vault — and the pass on open therefore never ran at all. It was a silent
|
||||
// no-op that put the first synchronisation a full minute after unlock, on the launch where
|
||||
// being current matters most. The later passes keep the check: by then, a busy flag means a
|
||||
// user is doing something.
|
||||
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
|
||||
@@ -252,7 +252,13 @@
|
||||
<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" />
|
||||
<!--
|
||||
Named because signing out lands here with the keyboard needing somewhere to go: the vault's
|
||||
controls have just been collapsed, and Focus() on a collapsed control is a no-op that is
|
||||
not replayed. See MainWindow.axaml.cs.
|
||||
-->
|
||||
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
||||
PlaceholderText="https://dodossh.example" />
|
||||
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
@@ -296,54 +302,22 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsLocked}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly. The terminal's native
|
||||
child window keeps Win32 focus when it is collapsed, so without that this box would
|
||||
show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="UNLOCK" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<!--
|
||||
Shown only when this machine has both a registered wrap and a keystore still willing to
|
||||
release the key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a
|
||||
machine that never had it invites the reading that something is broken — and the passphrase
|
||||
box beside it is not a fallback, it is the ordinary way in.
|
||||
-->
|
||||
<Button Classes="ghost" Content="USE WINDOWS HELLO"
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
<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." />
|
||||
<!--
|
||||
The unlock card, and the sign-out confirmation that replaces it. Both live in their own files:
|
||||
nothing inside this window can be laid out by a test — WebView2's adapter refuses the headless
|
||||
session's thread — so markup that stays here is markup nobody can measure. See UnlockCard.axaml.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsAskingForThePassphrase}">
|
||||
<views:UnlockCard x:Name="UnlockPane" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed
|
||||
while locked, so a shell left running is invisible as well as unstopped — and a screen
|
||||
saying "Unlock your vault" over a machine that still holds authenticated SSH channels is
|
||||
exactly the kind of half-truth this project writes down instead of implying. Visible only
|
||||
when there is something to disclose, so an ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
The confirmation, in place of the unlock card rather than under it: the card is already close
|
||||
to the height the window guarantees at its minimum, and a screen a user cannot read all of is
|
||||
worse than one that shows one question at a time.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsConfirmingSignOut}">
|
||||
<views:SignOutCard />
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
@@ -74,6 +74,23 @@ internal sealed partial class MainWindow : Window
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard belongs once the vault is no longer open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two ways out of an unlocked vault, and they land on different screens: locking shows the passphrase
|
||||
/// box, and signing out empties this machine and goes back to asking for a server. Both collapse the
|
||||
/// controls the keyboard was on, and <c>Focus()</c> on a collapsed control is a no-op that is not
|
||||
/// replayed when it is revealed — so a fixed target would leave whoever signed out with a window that
|
||||
/// swallows every keystroke until they click something.
|
||||
/// </remarks>
|
||||
private IInputElement ClosedVaultKeyboardHome => shell?.State switch
|
||||
{
|
||||
ShellState.Locked => UnlockPane.PassphraseBox,
|
||||
ShellState.NeedsServer => ServerUrlBox,
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The shortcuts the window owns.
|
||||
/// </summary>
|
||||
@@ -209,7 +226,7 @@ internal sealed partial class MainWindow : Window
|
||||
// change, and reacting to all of them would move focus during setup and sign-in.
|
||||
if (wasUnlocked && !unlocked)
|
||||
{
|
||||
ReleaseKeyboardTo(UnlockPassphrase);
|
||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||
}
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.PreferencesScreen"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
@@ -76,18 +77,49 @@
|
||||
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open, so nothing depends on this being pressed." />
|
||||
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
IsVisible="{Binding !IsOnline}"
|
||||
ToolTip.Tip="Opens your browser. Syncing needs a connection; everything else works without one." />
|
||||
ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume — after signing out, or once your identity provider stops accepting the one this machine held." />
|
||||
<Button Classes="ghost" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="11" Margin="0,8,0,0"
|
||||
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Deletes this machine's copy of the vault and withdraws its device key, so it goes back to knowing nothing. The vault stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Hidden rather than disabled while the confirmation is up, because the card below carries the
|
||||
button that actually does it and two sign-out buttons on one screen is one too many.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="danger" Content="SIGN OUT"
|
||||
Command="{Binding SignOutCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding !IsConfirmingSignOut}" />
|
||||
</Grid>
|
||||
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14" Margin="0,14,0,0"
|
||||
IsVisible="{Binding IsConfirmingSignOut}">
|
||||
<views:SignOutCard />
|
||||
</Border>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="NOT BUILT YET" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource TextDim}" />
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SignOutCard"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The sign-out confirmation.
|
||||
|
||||
One control used in two places — the preferences screen, where somebody is leaving a machine on
|
||||
purpose, and the unlock screen, where somebody has forgotten their passphrase and this is the only way
|
||||
forward. The two moments are different and the warning is not, which is why this is a shared control
|
||||
rather than two blocks that would drift apart.
|
||||
|
||||
It is a bare StackPanel and not a card: the two hosts frame it differently, because a card that centres
|
||||
itself is right over a lock screen and wrong halfway down a scrolling column of preferences.
|
||||
|
||||
Everything it says is something the state machine can actually answer. The count comes from the outbox,
|
||||
the shell count from the workspace, and the sentence about the identity provider is there because
|
||||
nothing here can end that session — see MainWindowViewModel.ConfirmSignOutAsync.
|
||||
-->
|
||||
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<TextBlock Classes="heading" FontSize="15" Text="Sign out of this machine?" />
|
||||
|
||||
<TextBlock Text="{Binding SignOutWarning}" Foreground="{StaticResource WarnText}"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="This deletes this machine's copy of the vault — the profile, the cached hosts, keys and passwords, and this machine's device key. Your vault is on the server and is not touched: signing in again brings it all back." />
|
||||
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Signing out does not close them, exactly as locking does not. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Content="SIGN OUT AND DELETE"
|
||||
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Your session at the identity provider is not ended by this — DodoSSH has no way to end it — so on a machine that is not yours, sign out there too." />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
|
||||
internal sealed partial class SignOutCard : UserControl
|
||||
{
|
||||
public SignOutCard() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.UnlockCard"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The unlock screen's contents.
|
||||
|
||||
Extracted from MainWindow.axaml so that it can be laid out and looked at: MainWindow cannot be shown in
|
||||
the headless session at all — WebView2's adapter refuses its thread, which LayoutHarnessTests pins — so
|
||||
markup that stays inside that file is markup no test can measure. This card is the one on the screen
|
||||
with the least room to spare and the most conditional content: a disclosure about shells left running,
|
||||
and a way out for a forgotten passphrase, both of which appear underneath a form that already fills most
|
||||
of the height the window guarantees.
|
||||
|
||||
A bare StackPanel rather than a card, because the card is the frame MainWindow puts around it.
|
||||
-->
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
|
||||
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly, and reached from the window through
|
||||
PassphraseBox. The terminal's native child window keeps Win32 focus when it is collapsed, so without
|
||||
that this box would show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
|
||||
Enter unlocks. A KeyBinding on the box rather than a handler on the window, because this is a property
|
||||
of the control the passphrase is typed into and not of the shell's state: the keyboard is put here on
|
||||
every lock, so the one gesture everybody makes after typing a password reaches the command this box
|
||||
exists for. A single-line TextBox does not handle Enter itself, so nothing is being fought over.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="UNLOCK" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<!--
|
||||
Shown only when this machine has both a registered wrap and a keystore still willing to release the
|
||||
key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a machine that never
|
||||
had it invites the reading that something is broken — and the passphrase box beside it is not a
|
||||
fallback, it is the ordinary way in.
|
||||
-->
|
||||
<Button Classes="ghost" Content="USE WINDOWS HELLO"
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed while
|
||||
locked, so a shell left running is invisible as well as unstopped — and a screen saying "Unlock your
|
||||
vault" over a machine that still holds authenticated SSH channels is exactly the kind of half-truth
|
||||
this project writes down instead of implying. Visible only when there is something to disclose, so an
|
||||
ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The way out of a forgotten passphrase, and the only one there is. Nothing can recover a passphrase —
|
||||
there is no server-side reset by design — so the honest offer is to reset this machine and sign in
|
||||
again, which costs whatever this machine had not yet pushed and nothing else. Stated here rather than
|
||||
left to be discovered, because somebody stuck on this screen has no other route and quitting the
|
||||
application does not help.
|
||||
-->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" />
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the vault is on the server and comes back." />
|
||||
<Button Classes="ghost" Content="RESET THIS MACHINE"
|
||||
Command="{Binding SignOutCommand}" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The unlock screen's contents.</summary>
|
||||
internal sealed partial class UnlockCard : UserControl
|
||||
{
|
||||
public UnlockCard() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard goes when the vault is locked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exposed the way <see cref="HostSidebar.KeyboardTarget"/> is, and for the same reason: the window
|
||||
/// owns the focus policy — it has to take the keyboard off the terminal's native child window first —
|
||||
/// and the control it hands it to belongs to whichever screen is showing.
|
||||
/// </remarks>
|
||||
internal TextBox PassphraseBox => UnlockPassphrase;
|
||||
}
|
||||
@@ -41,32 +41,49 @@ internal sealed class RefreshingAccessTokenProvider(
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private TokenSet tokens = initial;
|
||||
|
||||
/// <summary>
|
||||
/// The refresh token this provider currently holds, or null when none was granted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read rather than raised as an event, because the one caller — the shell, persisting it so a later
|
||||
/// launch can resume — has a moment of its own to do that in and no interest in the instant a
|
||||
/// rotation happens. A volatile read of a reference the refresh path replaces wholesale: the value is
|
||||
/// either the old set or the new one, never a half-written one.
|
||||
/// </remarks>
|
||||
internal string? RefreshToken => Volatile.Read(ref tokens).RefreshToken;
|
||||
|
||||
public async ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!tokens.NeedsRefresh(clock))
|
||||
var current = Volatile.Read(ref tokens);
|
||||
|
||||
if (!current.NeedsRefresh(clock))
|
||||
{
|
||||
return tokens.AccessToken;
|
||||
return current.AccessToken;
|
||||
}
|
||||
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (!tokens.NeedsRefresh(clock))
|
||||
current = Volatile.Read(ref tokens);
|
||||
|
||||
if (!current.NeedsRefresh(clock))
|
||||
{
|
||||
return tokens.AccessToken;
|
||||
return current.AccessToken;
|
||||
}
|
||||
|
||||
if (tokens.RefreshToken is null)
|
||||
if (current.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)
|
||||
var refreshed = await oidc.RefreshAsync(current.RefreshToken, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return tokens.AccessToken;
|
||||
Volatile.Write(ref tokens, refreshed);
|
||||
|
||||
return refreshed.AccessToken;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -77,6 +94,26 @@ internal sealed class RefreshingAccessTokenProvider(
|
||||
public void Dispose() => gate.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses to open anything, for the flows that must never reach a browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ServerConnection.ResumeAsync"/> uses only the refresh grant, which needs no user agent —
|
||||
/// but <see cref="OidcClient"/> takes a launcher in its constructor because its other two flows do. This
|
||||
/// makes "a resume never opens a browser" a property of the object rather than of the code path, so a
|
||||
/// future call that wandered into an interactive flow would fail loudly here instead of surprising
|
||||
/// somebody with a sign-in page that opened by itself.
|
||||
/// </remarks>
|
||||
internal sealed class NoBrowserLauncher : IBrowserLauncher
|
||||
{
|
||||
internal static NoBrowserLauncher Instance { get; } = new();
|
||||
|
||||
public Task OpenAsync(Uri url, CancellationToken cancellationToken) =>
|
||||
throw new InvalidOperationException(
|
||||
"This connection was resumed from a remembered sign-in and must not open a browser. "
|
||||
+ "Signing in interactively is something the user asks for.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a signed-in server offers, as everything above the session layer needs it.
|
||||
/// </summary>
|
||||
@@ -102,6 +139,18 @@ public interface IVaultServer : IDisposable
|
||||
|
||||
/// <summary>Sync tuning derived from what this server actually accepts.</summary>
|
||||
SyncOptions SyncOptions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The refresh token this connection holds right now, or null when the provider granted none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On the interface because remembering it is what lets a later launch come back online without a
|
||||
/// browser, and the thing doing the remembering — the shell — must not have to know whether it is
|
||||
/// holding a real connection or a test's stand-in. It changes over the life of a connection: a
|
||||
/// provider that rotates hands back a new one on every refresh, so a caller that persists this has
|
||||
/// to re-read it rather than cache it.
|
||||
/// </remarks>
|
||||
string? RefreshToken { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -182,6 +231,9 @@ public sealed class ServerConnection : IVaultServer
|
||||
MaxOperationsPerPush = Math.Clamp(Meta.MaxOperationsPerPush, 1, 500),
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? RefreshToken => tokens.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers the server, signs the user in through their browser, and returns the connection.
|
||||
/// </summary>
|
||||
@@ -232,6 +284,77 @@ public sealed class ServerConnection : IVaultServer
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-establishes a connection from a remembered refresh token, with no browser and no user present.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The difference between an application that is signed in and one that merely was. Without this, a
|
||||
/// machine that has been set up is offline from launch until somebody goes and presses a button —
|
||||
/// which means the sync loop, the outbox and a colleague's changes all wait on an action nobody has a
|
||||
/// reason to take.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Discovery runs again rather than being cached, because the client is deliberately configured by the
|
||||
/// server: the authority, the client id and the scopes are read from
|
||||
/// <c>/.well-known/dodossh-configuration</c> at every connection, so a deployment that moves its
|
||||
/// identity provider does not leave every client pinned to the old one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It fails rather than falling back when the token has been revoked or has expired, and that is the
|
||||
/// point of passing a launcher that refuses: a resume must never quietly become an interactive
|
||||
/// sign-in, which from a user's side is a browser window that opens on its own. The caller's answer to
|
||||
/// a failure is to stay offline and forget the token.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="serverUrl">The server this profile is enrolled against.</param>
|
||||
/// <param name="refreshToken">The remembered token.</param>
|
||||
/// <param name="clock">Time source, for token expiry.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public static async Task<ServerConnection> ResumeAsync(
|
||||
Uri serverUrl,
|
||||
string refreshToken,
|
||||
TimeProvider clock,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serverUrl);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
|
||||
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, NoBrowserLauncher.Instance, clock, BuildOidcOptions(configuration));
|
||||
|
||||
var tokenSet = await oidc.RefreshAsync(refreshToken, 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()
|
||||
{
|
||||
|
||||
@@ -77,6 +77,7 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
Conflicts = new ConflictStore(caches, protector, clock);
|
||||
Vault = new VaultStore(caches, clock);
|
||||
Unlock = new UnlockStore(caches, clock);
|
||||
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
|
||||
Hosts = new HostRepository(Items, Outbox, keyring);
|
||||
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
|
||||
Credentials = new CredentialRepository(Items, Outbox, keyring);
|
||||
@@ -133,6 +134,51 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
/// </remarks>
|
||||
internal UnlockStore Unlock { get; }
|
||||
|
||||
/// <remarks>
|
||||
/// Only reachable from an open session, which is the point rather than an accident of where it was
|
||||
/// put: the token is sealed under this session's cache key, so a locked machine cannot read it and
|
||||
/// therefore cannot reach the server at all. See <c>RememberedSignInStore</c>.
|
||||
/// </remarks>
|
||||
internal RememberedSignInStore SignIn { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Remembers the sign-in this machine currently holds, so a later launch can resume it.
|
||||
/// </summary>
|
||||
/// <param name="refreshToken">
|
||||
/// The refresh token the connection holds <em>now</em>. Providers rotate these, so a caller that
|
||||
/// notices a change has to call this again — the value is not a constant for the life of a sign-in.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task RememberSignInAsync(string refreshToken, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return SignIn.SaveAsync(refreshToken, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the sign-in this machine may resume, or null when there is none to resume.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null covers three situations that are one situation from the caller's side — nothing was ever
|
||||
/// remembered, the record was written under a different identity, or its tag no longer verifies — and
|
||||
/// the answer to all three is the same: sign in through the browser.
|
||||
/// </remarks>
|
||||
public Task<string?> ReadRememberedSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return SignIn.ReadAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Forgets the remembered sign-in.</summary>
|
||||
public Task ForgetSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return SignIn.ForgetAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -78,6 +78,37 @@ internal sealed class UnlockMaterialRow
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sign-in this machine may resume without opening a browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A single row, like <see cref="UnlockMaterialRow"/> and for the same reason: one cache holds one
|
||||
/// account.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The token is sealed under the LocalCacheKey, which is the whole point of storing it here.</b> A
|
||||
/// refresh token is a long-lived credential for the account — not for the vault, which nothing but the
|
||||
/// passphrase opens — so a copy of this file lifted off a stolen laptop must not be one. Sealing it under
|
||||
/// a key that exists only while the vault is unlocked means the sign-in can only be resumed by somebody
|
||||
/// who has already opened the vault, which is exactly the moment the application wants it: unlock, then
|
||||
/// come back online by itself. It also means a locked machine cannot reach the server at all, which is a
|
||||
/// consequence worth stating rather than a limitation to work around.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RememberedSignInRow
|
||||
{
|
||||
/// <summary>The only legal primary key.</summary>
|
||||
internal const int SingletonId = 1;
|
||||
|
||||
public int Id { get; set; } = SingletonId;
|
||||
|
||||
/// <summary>The refresh token, sealed under the LocalCacheKey. Ciphertext.</summary>
|
||||
public byte[] SealedRefreshToken { get; set; } = [];
|
||||
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A vault the user can reach, with the grant that opens it.</summary>
|
||||
/// <remarks>
|
||||
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
|
||||
|
||||
@@ -73,6 +73,7 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
||||
ArgumentNullException.ThrowIfNull(modelBuilder);
|
||||
|
||||
ConfigureUnlockMaterial(modelBuilder);
|
||||
ConfigureRememberedSignIn(modelBuilder);
|
||||
ConfigureVaults(modelBuilder);
|
||||
ConfigureItems(modelBuilder);
|
||||
ConfigureOutbox(modelBuilder);
|
||||
@@ -102,6 +103,26 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
||||
entity.Property(row => row.KdfSalt).IsRequired();
|
||||
});
|
||||
|
||||
/// <remarks>
|
||||
/// A table of its own rather than two more columns on <c>unlock_material</c>, because the two rows have
|
||||
/// opposite lifetimes: the unlock material is what makes this machine work offline and must survive
|
||||
/// everything short of a reset, while a remembered sign-in is dropped the moment the server stops
|
||||
/// accepting it. Deleting one must never be able to take the other with it.
|
||||
/// </remarks>
|
||||
private static void ConfigureRememberedSignIn(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<RememberedSignInRow>(entity =>
|
||||
{
|
||||
entity.ToTable(
|
||||
"remembered_sign_in",
|
||||
table => table.HasCheckConstraint(
|
||||
"ck_remembered_sign_in_singleton",
|
||||
$"id = {RememberedSignInRow.SingletonId}"));
|
||||
|
||||
entity.HasKey(row => row.Id);
|
||||
entity.Property(row => row.Id).ValueGeneratedNever();
|
||||
entity.Property(row => row.SealedRefreshToken).IsRequired();
|
||||
});
|
||||
|
||||
private static void ConfigureVaults(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<CachedVaultRow>(entity =>
|
||||
{
|
||||
|
||||
@@ -123,6 +123,59 @@ public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>,
|
||||
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empties the cache: every row of every table, and the pages they were written on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What signing out means on disk. The profile, the wrapped bundle, the item mirror, the outbox and
|
||||
/// the conflict log all go; the schema stays, so the application is usable again immediately and does
|
||||
/// not have to be restarted to be set up afresh.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Emptied rather than deleted, and then vacuumed.</b> Deleting the file is the obvious move and is
|
||||
/// worse here: the database is in WAL mode, so it is three files rather than one — a routine that
|
||||
/// removes <c>cache.db</c> and leaves <c>-wal</c> behind loses to a checkpoint that puts some of it
|
||||
/// back — and on Windows the pooled connections hold the file open, so the delete fails outright while
|
||||
/// the application is running. The <c>VACUUM</c> is the half that makes this a wipe rather than a
|
||||
/// hide: SQLite marks deleted pages free without overwriting them, so ciphertext and sealed records
|
||||
/// would otherwise stay legible in the file until something happened to reuse the page.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The migrations history is deliberately left alone. It describes the shape of the tables, not the
|
||||
/// user, and clearing it would make the next launch try to apply every migration to a schema that
|
||||
/// already has them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task ResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
// Taken from the model rather than listed here, so a table added later is emptied by a sign-out
|
||||
// without anyone having to remember this method exists.
|
||||
var tables = context.Model.GetEntityTypes()
|
||||
.Select(entityType => entityType.GetTableName())
|
||||
.OfType<string>()
|
||||
.Distinct(StringComparer.Ordinal);
|
||||
|
||||
foreach (var table in tables)
|
||||
{
|
||||
// A table name cannot be a parameter, so it is quoted rather than bound. The value comes from
|
||||
// this assembly's own model metadata and never from input; the doubling is what keeps that
|
||||
// true of a name somebody eventually writes with a quote in it.
|
||||
var sql = string.Concat("DELETE FROM \"", table.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
|
||||
|
||||
// EF1002 and CA2100 both describe interpolating a value into SQL, which is what the two lines
|
||||
// above are; the value is the one thing here that cannot come from a user.
|
||||
#pragma warning disable EF1002, CA2100
|
||||
await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore EF1002, CA2100
|
||||
}
|
||||
|
||||
await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DodoSSH.Client.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClientCacheContext))]
|
||||
[Migration("20260731082424_AddRememberedSignIn")]
|
||||
partial class AddRememberedSignIn
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<long>("ChangeSequence")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("change_sequence");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("version");
|
||||
|
||||
b.Property<byte[]>("WrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_data_key");
|
||||
|
||||
b.HasKey("VaultId", "EntityType", "EntityId")
|
||||
.HasName("pk_item");
|
||||
|
||||
b.HasIndex("VaultId", "ChangeSequence")
|
||||
.HasDatabaseName("ix_item_vault_id_change_sequence");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType")
|
||||
.HasDatabaseName("ix_item_vault_id_entity_type");
|
||||
|
||||
b.ToTable("item", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<bool>("IsPersonal")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_personal");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("permissions");
|
||||
|
||||
b.Property<bool>("RekeyRequired")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("rekey_required");
|
||||
|
||||
b.Property<Guid?>("TeamId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("team_id");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<byte[]>("WrappedVaultKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_vault_key");
|
||||
|
||||
b.HasKey("VaultId")
|
||||
.HasName("pk_vault");
|
||||
|
||||
b.ToTable("vault", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Acknowledged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("acknowledged");
|
||||
|
||||
b.Property<byte[]>("Detail")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("detail");
|
||||
|
||||
b.Property<long>("DetectedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("detected_at_utc");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_conflict");
|
||||
|
||||
b.HasIndex("VaultId", "Acknowledged")
|
||||
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
|
||||
|
||||
b.ToTable("conflict", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<byte?>("AncestorAadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_aad_version");
|
||||
|
||||
b.Property<Guid?>("AncestorDataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ancestor_data_key_id");
|
||||
|
||||
b.Property<uint?>("AncestorKeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_key_generation");
|
||||
|
||||
b.Property<byte[]>("AncestorPayload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_payload");
|
||||
|
||||
b.Property<byte[]>("AncestorProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_protected_fields");
|
||||
|
||||
b.Property<int?>("AncestorVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_version");
|
||||
|
||||
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_wrapped_data_key");
|
||||
|
||||
b.Property<int>("Attempts")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("attempts");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int?>("ExpectedVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("expected_version");
|
||||
|
||||
b.Property<bool>("IsParked")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_parked");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_error");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<Guid>("OperationId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("operation_id");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long>("QueuedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("queued_at_utc");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<byte[]>("WrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_data_key");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_outbox");
|
||||
|
||||
b.HasIndex("OperationId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_operation_id");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
|
||||
|
||||
b.HasIndex("VaultId", "IsParked", "Sequence")
|
||||
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
|
||||
|
||||
b.ToTable("outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("SealedRefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed_refresh_token");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_remembered_sign_in");
|
||||
|
||||
b.ToTable("remembered_sign_in", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<string>("Cursor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cursor");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<long?>("LastPulledAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pulled_at_utc");
|
||||
|
||||
b.Property<long?>("LastPushedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pushed_at_utc");
|
||||
|
||||
b.Property<long>("ServerTimeSkewMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("server_time_skew_ms");
|
||||
|
||||
b.HasKey("VaultId")
|
||||
.HasName("pk_sync_state");
|
||||
|
||||
b.ToTable("sync_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("DeviceId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("device_id");
|
||||
|
||||
b.Property<byte[]>("DeviceWrappedPrivateKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("device_wrapped_private_key");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("issuer");
|
||||
|
||||
b.Property<string>("KdfAlgorithm")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("kdf_algorithm");
|
||||
|
||||
b.Property<int>("KdfMemoryKibibytes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_memory_kibibytes");
|
||||
|
||||
b.Property<int>("KdfParallelism")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_parallelism");
|
||||
|
||||
b.Property<int>("KdfPasses")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_passes");
|
||||
|
||||
b.Property<byte[]>("KdfSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("kdf_salt");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("ServerUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("server_url");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<byte[]>("WrappedPrivateKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_private_key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_unlock_material");
|
||||
|
||||
b.ToTable("unlock_material", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRememberedSignIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "remembered_sign_in",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
sealed_refresh_token = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_remembered_sign_in", x => x.id);
|
||||
table.CheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "remembered_sign_in");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,30 @@ namespace DodoSSH.Client.Storage.Migrations
|
||||
b.ToTable("outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("SealedRefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed_refresh_token");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_remembered_sign_in");
|
||||
|
||||
b.ToTable("remembered_sign_in", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Crypto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// The sign-in this machine may resume without opening a browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One refresh token, sealed under the LocalCacheKey and bound to the user it belongs to. Everything about
|
||||
/// why it is sealed rather than stored — and what a locked machine therefore cannot do — is on
|
||||
/// <see cref="RememberedSignInRow"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The server it belongs to is deliberately not recorded here: <c>unlock_material</c> already holds it, and
|
||||
/// two copies of one fact is two facts that can disagree. A cache holds one account and one server.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RememberedSignInStore(
|
||||
IDbContextFactory<ClientCacheContext> contexts,
|
||||
LocalCacheProtector protector,
|
||||
Guid userId,
|
||||
TimeProvider clock)
|
||||
{
|
||||
/// <summary>Remembers a refresh token, replacing whatever was there.</summary>
|
||||
/// <remarks>
|
||||
/// Called again whenever the provider rotates the token. Keeping the one this client first received
|
||||
/// would leave a rotating provider refusing the next launch, which is the failure that reads as "the
|
||||
/// application randomly signs me out".
|
||||
/// </remarks>
|
||||
public async Task SaveAsync(string refreshToken, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<RememberedSignInRow>()
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new RememberedSignInRow();
|
||||
context.Add(row);
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(refreshToken);
|
||||
|
||||
try
|
||||
{
|
||||
row.SealedRefreshToken = protector.Protect(
|
||||
CryptoSpec.AadResourceType.User, userId, bytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The managed copy this method made, not the string it was handed — see the remark on
|
||||
// ReadAsync for what a .NET string does and does not allow here.
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
}
|
||||
|
||||
row.UpdatedAtUtc = clock.GetUtcNow();
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the remembered token, or null when there is none this key can open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Null rather than an exception for a record that will not open, on the same reasoning as
|
||||
/// <see cref="LocalCacheProtector.TryUnprotect"/>: a cache written under a different identity is an
|
||||
/// ordinary situation and the answer is to sign in again, not to fail.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It comes back as a <see cref="string"/>, which cannot be wiped. That is the same bargain the private
|
||||
/// key and password editors already make — every HTTP client on the way to the token endpoint wants a
|
||||
/// string — and pretending otherwise with a <c>SecureString</c> would buy nothing this process's memory
|
||||
/// does not already give away.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<string?> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<RememberedSignInRow>()
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var opened = protector.TryUnprotect(
|
||||
CryptoSpec.AadResourceType.User, userId, row.SealedRefreshToken);
|
||||
|
||||
return opened is null ? null : Encoding.UTF8.GetString(opened);
|
||||
}
|
||||
|
||||
/// <summary>Forgets the remembered sign-in, so the next launch has to use a browser.</summary>
|
||||
/// <remarks>
|
||||
/// Used when the provider refuses the token — a revoked session, a rotation this machine missed — as
|
||||
/// well as when the user signs out. Keeping a token that has already been refused would mean retrying
|
||||
/// it once a minute for the life of the profile.
|
||||
/// </remarks>
|
||||
public async Task ForgetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
await context.Set<RememberedSignInRow>()
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,20 @@ internal static class LayoutHarness
|
||||
/// <inheritdoc cref="TitleBarHeight" />
|
||||
internal const double StatusBarHeight = 24;
|
||||
|
||||
/// <summary>
|
||||
/// What a setup card leaves its contents: its maximum width, less the padding on both sides.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// From <c>Border.card</c> in <c>App.axaml</c> — <c>MaxWidth</c> 520 and <c>Padding</c> 24 — because the
|
||||
/// cards themselves live inside <c>MainWindow.axaml</c>, which cannot be laid out here at all. Measuring
|
||||
/// a card's contents at the size the card gives them is the closest this harness can get to the unlock
|
||||
/// screen, and it is the half that has something to blow: the frame is fixed and the contents are not.
|
||||
/// </remarks>
|
||||
internal const double CardContentWidth = 520 - (2 * 24);
|
||||
|
||||
/// <inheritdoc cref="CardContentWidth" />
|
||||
internal static double CardContentHeight => ScreenHeight - (2 * 24);
|
||||
|
||||
/// <summary>The height a screen actually gets at the window's minimum.</summary>
|
||||
internal static double ScreenHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.Views;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
using DodoSSH.Client.App.Views;
|
||||
@@ -54,6 +55,13 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
|
||||
/// <remarks>
|
||||
/// Constructed and never started: the sign-out card binds to the shell rather than to a vault, and what
|
||||
/// it shows comes from properties a fresh one already answers. Starting it would migrate a cache and
|
||||
/// read a profile, neither of which any rectangle here depends on.
|
||||
/// </remarks>
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -82,12 +90,23 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
// every sync pass out of a suite that is only measuring rectangles.
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-layout-{Guid.CreateVersion7():N}")),
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
new UnavailableDeviceKeyStore(),
|
||||
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
||||
TimeProvider.System,
|
||||
CheapProfile);
|
||||
|
||||
await SeedAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
await vault.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
@@ -363,6 +382,111 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- The unlock screen ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The card a locked application is entirely made of, in its two shapes: an ordinary launch, and one
|
||||
/// where shells were left running and the disclosure about them appears. It was extracted from
|
||||
/// <c>MainWindow.axaml</c> to be measurable at all — that window cannot be shown here, so anything
|
||||
/// inside it is unmeasured by construction — and it is the card with the least room to spare.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The status line is set to something long on purpose. It is bound to whatever the last thing that
|
||||
/// happened said, and the longest of those is a sentence about an expired sign-in, which is exactly the
|
||||
/// message this screen is most likely to be carrying on the launch where the extra rows also appear.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(2)]
|
||||
public async Task TheUnlockCardFitsTheCardItIsShownIn(int liveSessions)
|
||||
{
|
||||
shell.LiveSessionCount = liveSessions;
|
||||
shell.CanUnlockWithDevice = true;
|
||||
shell.StatusMessage = "Your sign-in has expired, so this machine is offline: the token endpoint "
|
||||
+ "returned 400: Invalid refresh token. Sign in again from Preferences to start syncing.";
|
||||
|
||||
await MeasureCardAsync(new UnlockCard());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheUnlockBoxTakesEnterAsUnlock()
|
||||
{
|
||||
// Enter is how everybody finishes typing a password, and this screen had no answer to it until the
|
||||
// gesture below existed: the passphrase box is where locking puts the keyboard, so the one thing a
|
||||
// user does without thinking did nothing at all until they found the button.
|
||||
//
|
||||
// The gesture is what can be asserted; that pressing it unlocks is ShellFlowTests' business,
|
||||
// against the command this binds to.
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var card = new UnlockCard { DataContext = shell };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
card, LayoutHarness.CardContentWidth, LayoutHarness.CardContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
var binding = card.PassphraseBox.KeyBindings.ShouldHaveSingleItem();
|
||||
|
||||
binding.Gesture.ShouldBe(new KeyGesture(Key.Enter));
|
||||
binding.Command.ShouldBeSameAs(shell.UnlockCommand);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- The sign-out confirmation ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one new card that has to share a screen with an unlock prompt, and the only one whose height
|
||||
/// depends on what it is saying: the warning is a sentence about the outbox, and the disclosure about
|
||||
/// shells left running appears only when there are some. Both are wrapped paragraphs, which is the
|
||||
/// shape that grows.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Measured in the space a card gives its contents rather than inside <c>MainWindow</c>, which cannot
|
||||
/// be laid out here — see <c>LayoutHarnessTests.WhyTheWindowItselfIsNeverShown</c>. What that leaves
|
||||
/// unchecked is the card's own frame, which is a fixed border and a constant padding.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSignOutCardFitsTheCardItIsShownIn()
|
||||
{
|
||||
// Its tallest shape: a shell left running adds a disclosure box that an ordinary sign-out does not
|
||||
// have, and a locked vault carries the longer of the two warnings.
|
||||
shell.LiveSessionCount = 1;
|
||||
|
||||
await MeasureCardAsync(new SignOutCard());
|
||||
}
|
||||
|
||||
/// <summary>Lays a setup-screen card out in the space <c>Border.card</c> gives its contents.</summary>
|
||||
private Task MeasureCardAsync(Control card) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
card.DataContext = shell;
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
card, LayoutHarness.CardContentWidth, LayoutHarness.CardContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>Lays the sidebar out at the width the hosts screen gives it.</summary>
|
||||
|
||||
@@ -77,6 +77,16 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
|
||||
/// <inheritdoc />
|
||||
public SyncOptions SyncOptions => SyncOptions.Default;
|
||||
|
||||
/// <summary>
|
||||
/// The refresh token this "connection" holds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Settable, because rotation is the half of remembering a sign-in that is easy to get wrong: a shell
|
||||
/// that persisted the token it first saw would leave a rotating provider refusing the next launch. A
|
||||
/// test changes this and asserts the new value reaches the cache.
|
||||
/// </remarks>
|
||||
public string? RefreshToken { get; set; } = "refresh-token-1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
||||
@@ -42,6 +43,18 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
private int signInAttempts;
|
||||
|
||||
/// <summary>How many times a shell has tried to resume a remembered sign-in, and with what.</summary>
|
||||
/// <remarks>
|
||||
/// Counted rather than merely allowed, because the interesting assertions about resuming are about how
|
||||
/// often it happens: once per launch when it works, and never again once the provider has refused.
|
||||
/// </remarks>
|
||||
private int resumeAttempts;
|
||||
|
||||
private string? resumedWith;
|
||||
|
||||
/// <summary>When set, resuming throws — how a revoked or rotated-away token is exercised.</summary>
|
||||
private Exception? resumeFailure;
|
||||
|
||||
private string directory = null!;
|
||||
private ClientPaths paths = null!;
|
||||
private ClientCacheFactory caches = null!;
|
||||
@@ -110,7 +123,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
deviceKeys,
|
||||
SignInAsync,
|
||||
TimeProvider.System,
|
||||
CheapProfile);
|
||||
CheapProfile,
|
||||
ResumeAsync);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -1966,7 +1980,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddHostAsync(vault, "prod-web-01");
|
||||
await AddHostAsync(vault, "prod-web-02");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(host => host.Label == "prod-web-01");
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-web-01", StringComparison.Ordinal));
|
||||
|
||||
vault.HostFilter = "prod";
|
||||
|
||||
@@ -1999,7 +2014,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "stage-web");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(host => host.Label == "stage-web");
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "stage-web", StringComparison.Ordinal));
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
@@ -2027,6 +2043,45 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
: Task.FromResult<IVaultServer>(server);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Counted and recorded, and never a browser: resuming is the path that must reach the token endpoint
|
||||
/// and nothing else. <see cref="resumeFailure"/> stands in for a provider that refuses.
|
||||
/// </remarks>
|
||||
private Task<IVaultServer> ResumeAsync(
|
||||
Uri serverUrl,
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
resumeAttempts++;
|
||||
resumedWith = refreshToken;
|
||||
|
||||
return resumeFailure is { } failure
|
||||
? Task.FromException<IVaultServer>(failure)
|
||||
: Task.FromResult<IVaultServer>(server);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A second shell over the same profile directory, as a relaunch of the application is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its sign-in delegate throws by default, which is the assertion rather than a convenience: a launch
|
||||
/// that reached it would be one that opened a browser at somebody, and every test using this is about
|
||||
/// a launch that must not.
|
||||
/// </remarks>
|
||||
private MainWindowViewModel Relaunch(
|
||||
IDeviceKeyStore? keys = null,
|
||||
MainWindowViewModel.ResumeHandler? resume = null) =>
|
||||
new(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
new VaultKnownHostStore(),
|
||||
keys ?? new UnavailableDeviceKeyStore(),
|
||||
(_, _) => throw new InvalidOperationException("The shell opened a browser on launch."),
|
||||
TimeProvider.System,
|
||||
CheapProfile,
|
||||
resume);
|
||||
|
||||
private async Task SignedInAsync()
|
||||
{
|
||||
await shell.StartAsync(Token);
|
||||
@@ -2210,6 +2265,296 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
}
|
||||
|
||||
// ---- Staying signed in, and syncing on its own ----
|
||||
|
||||
/// <remarks>
|
||||
/// The behaviour the whole remembered-sign-in mechanism exists for. Before it, a machine that had been
|
||||
/// set up launched <em>offline</em> and stayed there until somebody found the SIGN IN button on the
|
||||
/// preferences screen — so the sync loop ran once a minute against nothing, and a colleague's change
|
||||
/// arrived when a user happened to go looking for it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARelaunchComesBackOnlineWithoutOpeningABrowser()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
// The pass that remembers the sign-in. It is the one the loop runs when the vault opens; driven
|
||||
// here rather than raced against.
|
||||
await shell.Vault!.SyncOnOpenAsync(Token);
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
var relaunch = Relaunch(resume: ResumeAsync);
|
||||
await using var _ = relaunch.ConfigureAwait(false);
|
||||
|
||||
await relaunch.StartAsync(Token);
|
||||
|
||||
relaunch.State.ShouldBe(ShellState.Locked);
|
||||
relaunch.IsOnline.ShouldBeFalse(
|
||||
"the token is sealed under the vault's key, so a locked machine cannot reach the server");
|
||||
resumeAttempts.ShouldBe(0);
|
||||
|
||||
relaunch.Passphrase = Passphrase;
|
||||
await relaunch.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
await relaunch.Vault!.SyncOnOpenAsync(Token);
|
||||
|
||||
relaunch.IsOnline.ShouldBeTrue();
|
||||
resumedWith.ShouldBe("refresh-token-1");
|
||||
signInAttempts.ShouldBe(1, "the browser opened once, at setup, and must not open again");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Providers rotate refresh tokens on use, and a client that persisted only the first one it saw would
|
||||
/// present a retired token on the next launch and be signed out for no visible reason. This is the one
|
||||
/// failure in the mechanism that would look like flakiness rather than a bug.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARotatedTokenIsTheOneTheNextLaunchPresents()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
await shell.Vault!.SyncOnOpenAsync(Token);
|
||||
|
||||
server.RefreshToken = "refresh-token-2";
|
||||
await shell.Vault.SyncOnOpenAsync(Token);
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
var relaunch = Relaunch(resume: ResumeAsync);
|
||||
await using var _ = relaunch.ConfigureAwait(false);
|
||||
|
||||
await relaunch.StartAsync(Token);
|
||||
relaunch.Passphrase = Passphrase;
|
||||
await relaunch.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
await relaunch.Vault!.SyncOnOpenAsync(Token);
|
||||
|
||||
resumedWith.ShouldBe("refresh-token-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusedSignIn_IsSaidOnceAndNotRetriedForever()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
await shell.Vault!.SyncOnOpenAsync(Token);
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
// What a revoked session, or a rotation this machine missed, looks like from the token endpoint.
|
||||
resumeFailure = new OidcException(
|
||||
"The token endpoint returned 400: Invalid refresh token.", "invalid_grant");
|
||||
|
||||
var relaunch = Relaunch(resume: ResumeAsync);
|
||||
await using var _ = relaunch.ConfigureAwait(false);
|
||||
|
||||
await relaunch.StartAsync(Token);
|
||||
relaunch.Passphrase = Passphrase;
|
||||
await relaunch.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
// The vault opens regardless: nothing about being signed out stops a passphrase working.
|
||||
relaunch.State.ShouldBe(ShellState.Unlocked, relaunch.StatusMessage);
|
||||
|
||||
await relaunch.Vault!.SyncOnOpenAsync(Token);
|
||||
|
||||
relaunch.IsOnline.ShouldBeFalse();
|
||||
relaunch.Vault.Status.ShouldContain("expired", Case.Insensitive);
|
||||
|
||||
var attempted = resumeAttempts;
|
||||
attempted.ShouldBeGreaterThan(0);
|
||||
|
||||
// And the token is dropped rather than retried once a minute for the life of the profile.
|
||||
await relaunch.Vault.SyncOnOpenAsync(Token);
|
||||
|
||||
resumeAttempts.ShouldBe(attempted);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The pass that runs when the vault opens used to be skipped in the application and nowhere else: the
|
||||
/// loop is started from inside the unlock command, so the busy flag it yields to was raised by the
|
||||
/// unlock itself. It cost a full minute of a machine that was online and out of date, and no test saw
|
||||
/// it because every test called the pass by hand with nothing busy.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePassOnOpen_RunsEvenThoughUnlockingIsStillBusy()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
server.SyncFailure = null;
|
||||
|
||||
vault.PendingChanges.ShouldBe(1, "there must be something to push for this to mean anything");
|
||||
|
||||
// Standing in for the unlock command that is still running when the loop starts its first pass.
|
||||
vault.IsBusy = true;
|
||||
|
||||
await vault.SyncOnOpenAsync(Token);
|
||||
|
||||
vault.PendingChanges.ShouldBe(0, "the pass on open does not yield to the unlock that started it");
|
||||
server.LiveRowCount.ShouldBe(1);
|
||||
|
||||
vault.IsBusy = false;
|
||||
}
|
||||
|
||||
// ---- Signing out ----
|
||||
|
||||
[Fact]
|
||||
public async Task SigningOutIsAQuestionFirst()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
shell.SignOutCommand.Execute(null);
|
||||
|
||||
shell.IsConfirmingSignOut.ShouldBeTrue();
|
||||
shell.IsAskingForThePassphrase.ShouldBeFalse("the two cards swap rather than stack");
|
||||
shell.State.ShouldBe(ShellState.Unlocked, "arming the question changes nothing else");
|
||||
shell.Vault.ShouldNotBeNull();
|
||||
|
||||
shell.CancelSignOutCommand.Execute(null);
|
||||
|
||||
shell.IsConfirmingSignOut.ShouldBeFalse();
|
||||
shell.State.ShouldBe(ShellState.Unlocked);
|
||||
shell.Vault.ShouldNotBeNull("cancelling must not have closed anything");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SigningOut_DeletesThisMachinesCopyAndLeavesTheVaultOnTheServer()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
await AddHostAsync(shell.Vault!, "prod-db");
|
||||
|
||||
server.LiveRowCount.ShouldBe(1);
|
||||
|
||||
shell.SignOutCommand.Execute(null);
|
||||
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.NeedsServer);
|
||||
shell.Vault.ShouldBeNull("the vault's keys are gone");
|
||||
shell.IsOnline.ShouldBeFalse("and so is the connection");
|
||||
shell.AccountName.ShouldBeNull();
|
||||
shell.IsConfirmingSignOut.ShouldBeFalse();
|
||||
|
||||
server.LiveRowCount.ShouldBe(1, "the vault lives on the server and signing out does not touch it");
|
||||
|
||||
// A relaunch finds a machine that has never been set up, which is what "reset" has to mean.
|
||||
var relaunch = Relaunch();
|
||||
await using var _ = relaunch.ConfigureAwait(false);
|
||||
|
||||
await relaunch.StartAsync(Token);
|
||||
|
||||
relaunch.State.ShouldBe(ShellState.NeedsServer);
|
||||
relaunch.AccountName.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The half that makes signing out a reset rather than a wipe: the cache is emptied and immediately
|
||||
/// usable, so setting the machine up again needs no restart. It is also the way back for somebody who
|
||||
/// has forgotten their passphrase, which is why the button is on the unlock screen too.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AfterSigningOut_TheSameApplicationCanBeSetUpAgain()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
shell.SignOutCommand.Execute(null);
|
||||
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
|
||||
|
||||
await shell.SignInCommand.ExecuteAsync(null);
|
||||
|
||||
// The account is already enrolled — this machine forgot it, the server did not — so the wrap and
|
||||
// the salt are cached again from /me and the old passphrase still opens them.
|
||||
shell.State.ShouldBe(ShellState.Locked, shell.StatusMessage);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
shell.Vault.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SigningOutWithQueuedChanges_SaysHowManyWillBeLost()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
// A change this machine made and could not send is the one thing signing out destroys that
|
||||
// nothing else has a copy of, so the count is the whole point of the confirmation.
|
||||
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.PendingChanges.ShouldBe(1);
|
||||
|
||||
shell.SignOutCommand.Execute(null);
|
||||
|
||||
shell.SignOutWarning.ShouldContain("1 change");
|
||||
shell.SignOutWarning.ShouldContain("lost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SigningOutWhileLocked_AdmitsItCannotCountWhatWouldBeLost()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.SignOutCommand.Execute(null);
|
||||
|
||||
// The outbox is sealed under the key the vault holds, so a locked machine genuinely cannot count
|
||||
// it. Saying "nothing will be lost" here would be a claim this state cannot support.
|
||||
shell.SignOutWarning.ShouldContain("cannot be counted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SigningOut_WithdrawsThisMachineFromTheAccount()
|
||||
{
|
||||
// The leftover ADR 0007 is about: a device wrap on the account whose private half has just been
|
||||
// deleted is one nobody can account for and nothing can use.
|
||||
await UnlockedAsync();
|
||||
await shell.RegisterDeviceCommand.ExecuteAsync(null);
|
||||
|
||||
server.RegisteredDevices.Count.ShouldBe(1);
|
||||
|
||||
shell.SignOutCommand.Execute(null);
|
||||
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
|
||||
|
||||
server.RegisteredDevices.ShouldBeEmpty();
|
||||
deviceKeys.Peek().ShouldBeNull("this machine's own copy of the key goes too");
|
||||
|
||||
var relaunch = Relaunch(keys: deviceKeys);
|
||||
await using var _ = relaunch.ConfigureAwait(false);
|
||||
|
||||
await relaunch.StartAsync(Token);
|
||||
|
||||
relaunch.CanUnlockWithDevice.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Signing out is the strongest thing this application does to itself, and it deliberately does not do
|
||||
/// the one thing locking refuses to do either. The argument is the same one <c>LockAsync</c> carries:
|
||||
/// a session that authenticated before is still running somebody's job, and a button that destroyed it
|
||||
/// would be a button people stop pressing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SigningOut_LeavesOpenShellsRunningAndSaysSo()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
||||
TerminalSize.Default,
|
||||
Token);
|
||||
|
||||
shell.SignOutCommand.Execute(null);
|
||||
|
||||
shell.HasLiveSessions.ShouldBeTrue();
|
||||
shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running.");
|
||||
|
||||
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(1);
|
||||
shell.State.ShouldBe(ShellState.NeedsServer);
|
||||
}
|
||||
|
||||
private async Task UnlockedAsync()
|
||||
{
|
||||
await EnrolledAndConfirmedAsync();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Storage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The sign-in a machine may resume, and what emptying the cache does to it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two behaviours meet here for a reason: the refresh token is the one thing in this cache that is a
|
||||
/// credential for the <em>account</em> rather than for the vault, so both halves of its life — sealed
|
||||
/// while it is kept, gone when the user signs out — belong under one test class.
|
||||
/// </remarks>
|
||||
public sealed class RememberedSignInTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task ARememberedTokenRoundTrips()
|
||||
{
|
||||
using var harness = await CacheHarness.CreateAsync();
|
||||
|
||||
var store = Store(harness);
|
||||
|
||||
(await store.ReadAsync(Token)).ShouldBeNull("nothing has been remembered yet");
|
||||
|
||||
await store.SaveAsync("refresh-token-1", Token);
|
||||
|
||||
(await store.ReadAsync(Token)).ShouldBe("refresh-token-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RememberingAgain_ReplacesRatherThanAdds()
|
||||
{
|
||||
// What a rotating provider does on every refresh. A second row would be a constraint violation;
|
||||
// keeping the first would leave the next launch presenting a token the provider has retired.
|
||||
using var harness = await CacheHarness.CreateAsync();
|
||||
|
||||
var store = Store(harness);
|
||||
|
||||
await store.SaveAsync("refresh-token-1", Token);
|
||||
await store.SaveAsync("refresh-token-2", Token);
|
||||
|
||||
(await store.ReadAsync(Token)).ShouldBe("refresh-token-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnotherUsersCacheKey_DoesNotOpenIt()
|
||||
{
|
||||
// The whole reason this is sealed rather than stored. A cache file lifted off a machine cannot be
|
||||
// made to yield an account credential without the key that only an unlocked vault holds.
|
||||
using var owner = await CacheHarness.CreateAsync();
|
||||
using var stranger = await CacheHarness.CreateAsync();
|
||||
|
||||
await Store(owner).SaveAsync("refresh-token-1", Token);
|
||||
|
||||
var strangersView = new RememberedSignInStore(
|
||||
owner.Factory, stranger.Protector, CacheHarness.UserId, TimeProvider.System);
|
||||
|
||||
(await strangersView.ReadAsync(Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgettingIt_LeavesNothingToResume()
|
||||
{
|
||||
using var harness = await CacheHarness.CreateAsync();
|
||||
|
||||
var store = Store(harness);
|
||||
|
||||
await store.SaveAsync("refresh-token-1", Token);
|
||||
await store.ForgetAsync(Token);
|
||||
|
||||
(await store.ReadAsync(Token)).ShouldBeNull();
|
||||
|
||||
// And forgetting what is not there is not an error: it runs on a sign-out from a machine that
|
||||
// never remembered one.
|
||||
await store.ForgetAsync(Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResettingTheCache_EmptiesEveryTableAndKeepsTheSchema()
|
||||
{
|
||||
// What signing out does on disk. Every row goes — the profile an unlock reads, the item mirror,
|
||||
// the outbox, the remembered sign-in — and the database is immediately usable again, because the
|
||||
// application has to be able to be set up afresh without being restarted.
|
||||
using var harness = await CacheHarness.CreateAsync();
|
||||
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
await harness.Unlock.SaveAsync(Material(), Token);
|
||||
await harness.Items.SaveAsync(CacheHarness.Item(entityId), Token);
|
||||
await harness.Outbox.QueueAsync(CacheHarness.Change(entityId), Token);
|
||||
await Store(harness).SaveAsync("refresh-token-1", Token);
|
||||
|
||||
await harness.Factory.ResetAsync(Token);
|
||||
|
||||
(await harness.Unlock.ReadAsync(Token)).ShouldBeNull("the profile is what makes a machine enrolled");
|
||||
|
||||
(await harness.Items
|
||||
.ListAsync(CacheHarness.VaultId, SyncEntityType.Host, includeDeleted: true, Token))
|
||||
.ShouldBeEmpty();
|
||||
(await harness.Outbox.ListAllAsync(CacheHarness.VaultId, Token)).ShouldBeEmpty();
|
||||
(await Store(harness).ReadAsync(Token)).ShouldBeNull();
|
||||
|
||||
// Usable, not merely empty: writing to it again must not need a migration.
|
||||
await harness.Unlock.SaveAsync(Material(), Token);
|
||||
(await harness.Unlock.ReadAsync(Token)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private static RememberedSignInStore Store(CacheHarness harness) =>
|
||||
new(harness.Factory, harness.Protector, CacheHarness.UserId, TimeProvider.System);
|
||||
|
||||
private static StoredUnlockMaterial Material() =>
|
||||
new(
|
||||
"https://dodossh.example",
|
||||
CacheHarness.UserId,
|
||||
"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