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:
2026-07-31 11:07:36 +02:00
parent 94e11f5e38
commit 0b261c4d39
28 changed files with 2323 additions and 80 deletions
@@ -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>