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,200 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>What the server said about this account.</summary>
|
||||
public enum ProvisionStatus
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>
|
||||
/// No identity key exists yet. The user must choose a passphrase and enroll before anything else
|
||||
/// works.
|
||||
/// </summary>
|
||||
EnrollmentRequired = 1,
|
||||
|
||||
/// <summary>Enrolled, and everything an offline unlock needs is now cached.</summary>
|
||||
Ready = 2,
|
||||
}
|
||||
|
||||
/// <summary>The result of talking to the server about this account.</summary>
|
||||
/// <param name="Status">What happened.</param>
|
||||
/// <param name="Me">The profile the server reported.</param>
|
||||
/// <param name="RecoveryCode">
|
||||
/// Present only immediately after enrolling. <b>Must be shown once and never stored.</b> Losing this
|
||||
/// along with the passphrase and every enrolled device means the vault is unrecoverable, and there is no
|
||||
/// server-side reset by design — see docs/crypto.md §10.
|
||||
/// </param>
|
||||
/// <param name="Message">Something to show the user.</param>
|
||||
public sealed record ProvisionOutcome(
|
||||
ProvisionStatus Status,
|
||||
MeResponse Me,
|
||||
string? RecoveryCode,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Gets this machine from "signed in" to "has everything an offline unlock needs".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The only part of the client that requires a network. Everything it does is in service of the part
|
||||
/// that does not: it caches the KDF salt, the wrapped identity bundle and the vault grants, so every
|
||||
/// later launch opens the vault with nothing but the passphrase.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// After enrolling it re-reads <c>/me</c> rather than caching what it believes it sent. That is a
|
||||
/// deliberate round trip: it proves the server stored what this client thinks it did, and the passphrase
|
||||
/// the user just chose is then verified against the cached wrap on the very next unlock rather than on
|
||||
/// some future launch when they have forgotten which one they typed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AccountProvisioner(
|
||||
IAccountApi api,
|
||||
IKeyBindingAuthorizer keyBinding,
|
||||
ClientCacheFactory caches,
|
||||
TimeProvider clock,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
{
|
||||
/// <summary>Reads the account and caches whatever an offline unlock will need.</summary>
|
||||
public async Task<ProvisionOutcome> RefreshAsync(
|
||||
string serverUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);
|
||||
|
||||
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (me.EnrollmentRequired)
|
||||
{
|
||||
return new ProvisionOutcome(
|
||||
ProvisionStatus.EnrollmentRequired,
|
||||
me,
|
||||
null,
|
||||
"This account has no vault key yet. Choose a passphrase to create one.");
|
||||
}
|
||||
|
||||
await CacheAsync(serverUrl, me, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ProvisionOutcome(
|
||||
ProvisionStatus.Ready, me, null, "Signed in. Unlock with your vault passphrase.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates this account's identity key and personal vault.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">The server this profile belongs to.</param>
|
||||
/// <param name="passphrase">The vault passphrase. Never transmitted or stored.</param>
|
||||
/// <param name="deviceName">Human-readable name for this machine, shown in the key statement.</param>
|
||||
/// <param name="vaultName">Display name for the personal vault.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <remarks>
|
||||
/// <b>No device key is registered.</b> Its private half belongs in the OS keystore, and nothing wires
|
||||
/// one up yet — so registering it would put a wrap on the server that no key can open and would make
|
||||
/// the account's device list claim this machine can unlock without a passphrase. Until the keystore
|
||||
/// is wired, the passphrase is required on every launch. That is a limitation, not a design choice.
|
||||
/// </remarks>
|
||||
public async Task<ProvisionOutcome> EnrollAsync(
|
||||
string serverUrl,
|
||||
string passphrase,
|
||||
string deviceName,
|
||||
string vaultName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);
|
||||
ArgumentException.ThrowIfNullOrEmpty(passphrase);
|
||||
|
||||
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!me.EnrollmentRequired)
|
||||
{
|
||||
// Someone else already enrolled this account — another machine, or a retry whose answer was
|
||||
// lost. Caching what is there is the right move; re-enrolling would replace a key other
|
||||
// people may already have wrapped vault keys to.
|
||||
await CacheAsync(serverUrl, me, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ProvisionOutcome(
|
||||
ProvisionStatus.Ready,
|
||||
me,
|
||||
null,
|
||||
"This account was already enrolled. Unlock with your existing vault passphrase.");
|
||||
}
|
||||
|
||||
var enrollment = new ClientEnrollment(api, keyBinding, clock, passphraseProfile);
|
||||
|
||||
var outcome = await enrollment
|
||||
.EnrollAsync(me, passphrase, deviceName, vaultName, bindThisDevice: false, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var enrolled = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await CacheAsync(serverUrl, enrolled, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ProvisionOutcome(
|
||||
ProvisionStatus.Ready,
|
||||
enrolled,
|
||||
outcome.RecoveryCode,
|
||||
"Your vault was created. Write the recovery code down before continuing.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The session keys are re-derived from the cached wrap at unlock, so nothing here needs to
|
||||
// survive this method — and a vault key left in a managed array is a vault key in a heap dump.
|
||||
outcome.Bundle.Dispose();
|
||||
CryptographicOperations.ZeroMemory(outcome.PersonalVaultKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Both halves matter. Without the unlock material there is no offline unlock; without the vault
|
||||
/// grants an offline launch could open the identity bundle and still not decrypt a single item.
|
||||
/// </remarks>
|
||||
private async Task CacheAsync(
|
||||
string serverUrl,
|
||||
MeResponse me,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (me.WrappedPrivateKey is null || me.KdfParameters is null || me.KeyGeneration is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The server reported an enrolled account without the material needed to unlock it. "
|
||||
+ "Refusing to cache a profile that could never be opened.");
|
||||
}
|
||||
|
||||
await new UnlockStore(caches, clock).SaveAsync(
|
||||
new StoredUnlockMaterial(
|
||||
serverUrl,
|
||||
me.UserId,
|
||||
me.Issuer,
|
||||
me.Subject,
|
||||
me.Email,
|
||||
me.DisplayName,
|
||||
(uint)me.KeyGeneration.Value,
|
||||
me.WrappedPrivateKey,
|
||||
me.KdfParameters,
|
||||
clock.GetUtcNow()),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await new VaultStore(caches, clock).ReplaceAllAsync(
|
||||
[.. me.Vaults.Select(ToStored)],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static StoredVault ToStored(VaultSummary summary) =>
|
||||
new(
|
||||
summary.VaultId,
|
||||
summary.Name,
|
||||
summary.IsPersonal,
|
||||
summary.TeamId,
|
||||
summary.KeyGeneration,
|
||||
summary.Permissions,
|
||||
summary.WrappedVaultKey,
|
||||
summary.RekeyRequired);
|
||||
}
|
||||
Reference in New Issue
Block a user