Public Access
Wire the Avalonia shell to the vault
The host list now comes from the vault instead of from a form. A fresh machine takes a server URL, signs in through the browser, enrolls, and from then on opens with the passphrase alone. DodoSSH.Client.Session is the composition layer: where a profile lives, how it unlocks, and how a machine gets one. ClientPaths picks a non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%, because a SQLite cache that roams between two machines is a corrupt one, and each machine's outbox is its own. SessionOpener needs no transport at all and could not reach one if it wanted to; that is the offline unlock, asserted rather than asserted about. A wrong passphrase, a stale KDF and a grant revoked by a rekey are three different answers, because the remedies are three different things and telling someone to retype a passphrase that was never the problem is worse than saying nothing. The shell's states are the onboarding story. The recovery code gets its own state that cannot be clicked past: it exists for one moment, losing it with the passphrase loses the vault, and there is no server-side reset by design. It is dropped from memory on confirmation rather than merely hidden. Sign-in is a delegate over IVaultServer, so the whole state machine runs in a test against an in-memory server — no browser, no identity provider, no toolkit. The view models are plain observable objects, which is what makes that possible. What it does not cover is whether the XAML binds to the right names; that needs a rendered tree and Avalonia.Headless, and is its own piece of work. Three things found by doing it rather than by reading it: - Pooled SQLite connections keep the database file open after the last context is disposed. On Windows that means locked, so the application could never replace its own cache — and a test could not clean up after itself, which is how it surfaced. Dispose now clears the pool. - EF's SQLite provider puts the database in WAL mode, so the cache is three files. A comment in ClientCacheFactory claimed the opposite; reading PRAGMA journal_mode off a real launch settled it. WAL is the right mode here — a sync pass writes while the interface reads — so the comment was wrong on the merits as well as on the fact. - Enrolling a device key with nowhere to keep the private half would put a wrap on the server nobody can open and make the device list claim this machine can unlock without a passphrase. Device binding is now optional and the shell declines it until the OS keystore is wired. Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db and migrated it on first launch, and msedgewebview2 held an established connection to the data plane while the unlock overlay covered it — which is the point of covering the WebView rather than collapsing it, since a NativeWebView that is never laid out is never realised. 630 tests, up from 593. The recovery-code gate and the offline unlock were each verified by breaking them and watching the right test fail. Still to do for M1's actual definition of done: the manual run against the real API and a real Keycloak. Credentials are not a synced entity type yet, so a connection still asks for a password, and the interface says so rather than implying otherwise.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>Why an unlock did or did not produce a session.</summary>
|
||||
public enum UnlockStatus
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>The vault is open.</summary>
|
||||
Unlocked = 1,
|
||||
|
||||
/// <summary>
|
||||
/// This machine has never been enrolled, so there is nothing here to unlock. The user has to sign in
|
||||
/// to a server first, which needs a network.
|
||||
/// </summary>
|
||||
NotEnrolled = 2,
|
||||
|
||||
/// <summary>
|
||||
/// The passphrase did not open the wrap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The overwhelmingly common failure, and a return value rather than an exception for that reason.
|
||||
/// It is also indistinguishable from a tampered wrap, which is correct: the AEAD tag is the only
|
||||
/// evidence either way, and no passphrase verifier is stored anywhere. See docs/crypto.md §2.
|
||||
/// </remarks>
|
||||
WrongPassphrase = 3,
|
||||
|
||||
/// <summary>
|
||||
/// The identity opened but no vault grant did, so there is nothing readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What a rekey looks like before new grants arrive. Distinguished from a wrong passphrase because
|
||||
/// the remedy is completely different — this one needs a member with Share to finish the rekey, and
|
||||
/// telling the user to retype their passphrase would be actively misleading.
|
||||
/// </remarks>
|
||||
NoReadableVault = 4,
|
||||
|
||||
/// <summary>The cached KDF parameters are not something this build can use.</summary>
|
||||
UnsupportedKdf = 5,
|
||||
}
|
||||
|
||||
/// <summary>The result of an unlock attempt.</summary>
|
||||
/// <param name="Status">What happened.</param>
|
||||
/// <param name="Session">The open vault, present only when <paramref name="Status"/> is unlocked.</param>
|
||||
/// <param name="Message">Something to show the user. Never contains secret material.</param>
|
||||
public sealed record UnlockOutcome(UnlockStatus Status, VaultSession? Session, string Message)
|
||||
{
|
||||
/// <summary>Whether a session came back.</summary>
|
||||
public bool IsUnlocked => Status == UnlockStatus.Unlocked && Session is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the vault from what is already on this machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This path touches no network, deliberately and testably.</b> The Argon2id salt, its cost
|
||||
/// parameters and the wrapped identity bundle are all cached at enrollment, so deriving the master key
|
||||
/// and opening the bundle need nothing but the passphrase. Fetching any of it at unlock time would make
|
||||
/// an offline launch impossible, which is the single most common moment a user actually needs their
|
||||
/// hosts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing derived here is persisted. The master key exists for the duration of this method and is
|
||||
/// zeroed before it returns; what survives is the cache subkey and the identity keys, in the session,
|
||||
/// until the session is disposed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SessionOpener(
|
||||
ClientCacheFactory caches,
|
||||
TimeProvider clock,
|
||||
SyncOptions? options = null)
|
||||
{
|
||||
private readonly SyncOptions options = options ?? SyncOptions.Default;
|
||||
|
||||
/// <summary>Reads who this machine is enrolled as, without needing a passphrase.</summary>
|
||||
/// <remarks>
|
||||
/// Lets the unlock screen greet the user by name and show which server they are enrolled against,
|
||||
/// which is the difference between an unlock prompt and an unexplained password box.
|
||||
/// </remarks>
|
||||
public Task<StoredUnlockMaterial?> ReadProfileAsync(CancellationToken cancellationToken) =>
|
||||
new UnlockStore(caches, clock).ReadAsync(cancellationToken);
|
||||
|
||||
/// <summary>Attempts to open the vault.</summary>
|
||||
public async Task<UnlockOutcome> UnlockAsync(string passphrase, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(passphrase);
|
||||
|
||||
var profile = await ReadProfileAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (profile is null)
|
||||
{
|
||||
return new UnlockOutcome(
|
||||
UnlockStatus.NotEnrolled,
|
||||
null,
|
||||
"This machine is not enrolled yet. Sign in to a DodoSSH server to set it up.");
|
||||
}
|
||||
|
||||
if (!TryReadKdf(profile, out var kdf))
|
||||
{
|
||||
return new UnlockOutcome(
|
||||
UnlockStatus.UnsupportedKdf,
|
||||
null,
|
||||
$"The stored key derivation settings ('{profile.KdfParameters.Algorithm}') are not "
|
||||
+ "supported by this version. Update DodoSSH.");
|
||||
}
|
||||
|
||||
var bundle = OpenBundle(profile, passphrase, kdf, out var protector);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
return new UnlockOutcome(
|
||||
UnlockStatus.WrongPassphrase, null, "That passphrase did not open the vault.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await BuildSessionAsync(profile, bundle, protector!, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
protector!.Dispose();
|
||||
bundle.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The master key lives only inside this method. Both things derived from it — the cache subkey and
|
||||
/// the identity bundle — outlive it, which is why they are produced together here rather than by two
|
||||
/// calls that would each need the master key again.
|
||||
/// </remarks>
|
||||
private static UserSecretBundle? OpenBundle(
|
||||
StoredUnlockMaterial profile,
|
||||
string passphrase,
|
||||
Argon2Profile kdf,
|
||||
out LocalCacheProtector? protector)
|
||||
{
|
||||
protector = null;
|
||||
|
||||
using var master = MasterKey.Derive(passphrase, profile.KdfParameters.Salt, kdf);
|
||||
|
||||
var descriptor = DshAad.UserSecretBundle(profile.UserId, profile.KeyGeneration);
|
||||
var bundle = master.TryOpenBundle(profile.WrappedPrivateKey, descriptor);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
protector = LocalCacheProtector.From(master);
|
||||
return bundle;
|
||||
}
|
||||
catch
|
||||
{
|
||||
bundle.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<UnlockOutcome> BuildSessionAsync(
|
||||
StoredUnlockMaterial profile,
|
||||
UserSecretBundle bundle,
|
||||
LocalCacheProtector protector,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vaults = await new VaultStore(caches, clock)
|
||||
.ListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var keyring = VaultKeyring.Open(bundle, vaults);
|
||||
|
||||
try
|
||||
{
|
||||
var active = vaults.FirstOrDefault(vault => keyring.CanRead(vault.VaultId));
|
||||
|
||||
if (active is null)
|
||||
{
|
||||
keyring.Dispose();
|
||||
protector.Dispose();
|
||||
bundle.Dispose();
|
||||
|
||||
return new UnlockOutcome(
|
||||
UnlockStatus.NoReadableVault,
|
||||
null,
|
||||
vaults.Count == 0
|
||||
? "No vaults are cached on this machine yet. Sign in to synchronise them."
|
||||
: "Your key does not open any cached vault. It was probably rotated; a member "
|
||||
+ "with sharing rights needs to re-issue your access.");
|
||||
}
|
||||
|
||||
var session = new VaultSession(
|
||||
profile, vaults, active.VaultId, bundle, protector, keyring, caches, clock, options);
|
||||
|
||||
return new UnlockOutcome(UnlockStatus.Unlocked, session, $"Unlocked '{active.Name}'.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
keyring.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The parameters travel with the wrap so that raising them later is a per-user migration at next
|
||||
/// unlock rather than a breaking change. The cost of that is having to handle values this build does
|
||||
/// not recognise, which is what this is: a clear message beats an exception from inside libsodium.
|
||||
/// </remarks>
|
||||
private static bool TryReadKdf(StoredUnlockMaterial profile, out Argon2Profile kdf)
|
||||
{
|
||||
kdf = Argon2Profile.PassphraseDefault;
|
||||
|
||||
if (!string.Equals(profile.KdfParameters.Algorithm, "argon2id", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
kdf = Argon2Profile.FromStoredParameters(
|
||||
profile.KdfParameters.MemoryKibibytes,
|
||||
profile.KdfParameters.Passes,
|
||||
profile.KdfParameters.Parallelism);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user