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:
@@ -15,8 +15,9 @@ namespace DodoSSH.Client.Api;
|
||||
/// <param name="Bundle">The identity key pair, unlocked for this session.</param>
|
||||
/// <param name="PersonalVaultKey">The personal vault's key, in plaintext for this session.</param>
|
||||
/// <param name="DevicePrivateKey">
|
||||
/// The enrolled device's X25519 private key. Belongs in the OS keystore — it is what lets a later
|
||||
/// launch unlock without the passphrase.
|
||||
/// The enrolled device's X25519 private key, or <see langword="null"/> when no device was bound.
|
||||
/// Belongs in the OS keystore — it is what lets a later launch unlock without the passphrase, and it
|
||||
/// is the only thing that can open the device wrap held server-side.
|
||||
/// </param>
|
||||
/// <param name="RecoveryCode">
|
||||
/// The generated recovery code, which must be shown to the user once and never stored. Losing this
|
||||
@@ -27,7 +28,7 @@ public sealed record EnrollmentOutcome(
|
||||
EnrollmentResponse Response,
|
||||
UserSecretBundle Bundle,
|
||||
byte[] PersonalVaultKey,
|
||||
byte[] DevicePrivateKey,
|
||||
byte[]? DevicePrivateKey,
|
||||
string RecoveryCode);
|
||||
|
||||
/// <summary>
|
||||
@@ -46,10 +47,19 @@ public sealed record EnrollmentOutcome(
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClientEnrollment(
|
||||
DodoSshApiClient api,
|
||||
IAccountApi api,
|
||||
IKeyBindingAuthorizer keyBinding,
|
||||
TimeProvider clock)
|
||||
TimeProvider clock,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
{
|
||||
/// <remarks>
|
||||
/// Configurable because the cost is a product decision, not a constant: the plan exposes 128, 256 and
|
||||
/// 512 MiB security levels, and the parameters travel with the wrap so a user's choice is theirs
|
||||
/// alone. It also lets a test pay milliseconds instead of a third of a second to prove something that
|
||||
/// has nothing to do with how hard the passphrase is to attack.
|
||||
/// </remarks>
|
||||
private readonly Argon2Profile passphraseProfile = passphraseProfile ?? Argon2Profile.PassphraseDefault;
|
||||
|
||||
/// <summary>Bytes of entropy behind a recovery code.</summary>
|
||||
private const int RecoveryEntropyBytes = 20;
|
||||
|
||||
@@ -63,12 +73,22 @@ public sealed class ClientEnrollment(
|
||||
/// <param name="passphrase">The vault passphrase. Never transmitted or stored.</param>
|
||||
/// <param name="deviceName">Human-readable name for this machine.</param>
|
||||
/// <param name="vaultName">Display name for the personal vault. Plaintext, as vault names are.</param>
|
||||
/// <param name="bindThisDevice">
|
||||
/// Whether to register a device key so a later launch can unlock without the passphrase.
|
||||
/// <para>
|
||||
/// Pass <see langword="false"/> when the caller has nowhere durable to keep the private half. A
|
||||
/// device wrap whose private key does not survive the process is a row on the server that nobody can
|
||||
/// ever open, and it makes the account's device list claim a capability this machine does not have —
|
||||
/// which is worse than not offering it.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<EnrollmentOutcome> EnrollAsync(
|
||||
MeResponse me,
|
||||
string passphrase,
|
||||
string deviceName,
|
||||
string vaultName,
|
||||
bool bindThisDevice,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(me);
|
||||
@@ -91,7 +111,8 @@ public sealed class ClientEnrollment(
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var request = BuildRequest(
|
||||
me, bundle, statement, idToken, passphrase, vaultName, now, out var material);
|
||||
me, bundle, statement, idToken, passphrase, passphraseProfile, vaultName,
|
||||
bindThisDevice, now, out var material);
|
||||
|
||||
var response = await api.EnrollAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -130,7 +151,7 @@ public sealed class ClientEnrollment(
|
||||
/// <summary>Secrets the caller keeps after a successful enrollment.</summary>
|
||||
private readonly record struct SessionMaterial(
|
||||
byte[] VaultKey,
|
||||
byte[] DevicePrivateKey,
|
||||
byte[]? DevicePrivateKey,
|
||||
string RecoveryCode);
|
||||
|
||||
/// <remarks>
|
||||
@@ -143,7 +164,9 @@ public sealed class ClientEnrollment(
|
||||
KeyStatement statement,
|
||||
string idToken,
|
||||
string passphrase,
|
||||
Argon2Profile passphraseProfile,
|
||||
string vaultName,
|
||||
bool bindThisDevice,
|
||||
DateTimeOffset now,
|
||||
out SessionMaterial material)
|
||||
{
|
||||
@@ -158,8 +181,7 @@ public sealed class ClientEnrollment(
|
||||
byte[] passphraseWrap;
|
||||
byte[] recoveryWrap;
|
||||
|
||||
using (var master = MasterKey.Derive(
|
||||
passphrase, passphraseSalt, Argon2Profile.PassphraseDefault))
|
||||
using (var master = MasterKey.Derive(passphrase, passphraseSalt, passphraseProfile))
|
||||
{
|
||||
passphraseWrap = master.WrapBundle(bundle, descriptor);
|
||||
}
|
||||
@@ -171,19 +193,24 @@ public sealed class ClientEnrollment(
|
||||
recoveryWrap = recoveryMaster.WrapBundle(bundle, descriptor);
|
||||
}
|
||||
|
||||
using var deviceKey = Key.Create(
|
||||
KeyAgreementAlgorithm.X25519,
|
||||
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
|
||||
byte[]? devicePublicKey = null;
|
||||
byte[]? deviceWrap = null;
|
||||
byte[]? devicePrivateKey = null;
|
||||
|
||||
var devicePublicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
var deviceWrap = bundle.SealTo(devicePublicKey, descriptor);
|
||||
if (bindThisDevice)
|
||||
{
|
||||
using var deviceKey = Key.Create(
|
||||
KeyAgreementAlgorithm.X25519,
|
||||
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
|
||||
|
||||
devicePublicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
deviceWrap = bundle.SealTo(devicePublicKey, descriptor);
|
||||
devicePrivateKey = deviceKey.Export(KeyBlobFormat.RawPrivateKey);
|
||||
}
|
||||
|
||||
var vault = BuildPersonalVault(me, bundle, vaultName, now, out var vaultKey);
|
||||
|
||||
material = new SessionMaterial(
|
||||
vaultKey,
|
||||
deviceKey.Export(KeyBlobFormat.RawPrivateKey),
|
||||
recoveryCode);
|
||||
material = new SessionMaterial(vaultKey, devicePrivateKey, recoveryCode);
|
||||
|
||||
return new EnrollmentRequest(
|
||||
Statement: statement,
|
||||
@@ -192,7 +219,7 @@ public sealed class ClientEnrollment(
|
||||
KeyStatementCodec.Encode(ToFields(statement))),
|
||||
IdentityProviderToken: idToken,
|
||||
WrappedPrivateKey: passphraseWrap,
|
||||
KdfParameters: ToContract(passphraseSalt, Argon2Profile.PassphraseDefault),
|
||||
KdfParameters: ToContract(passphraseSalt, passphraseProfile),
|
||||
DevicePublicKey: devicePublicKey,
|
||||
DeviceWrappedPrivateKey: deviceWrap,
|
||||
RecoveryWrappedPrivateKey: recoveryWrap,
|
||||
|
||||
@@ -18,6 +18,24 @@ public interface IAccessTokenProvider
|
||||
ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account calls: who am I, and publish my first key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separated for the same reason as <see cref="ISyncApi"/>. What the session layer does with these is
|
||||
/// decide between enrolling and unlocking, and persist the result so the next launch needs no network;
|
||||
/// testing that against a stubbed HTTP transport would prove the right bytes were sent and nothing
|
||||
/// about the decision.
|
||||
/// </remarks>
|
||||
public interface IAccountApi
|
||||
{
|
||||
/// <summary>Reads the caller's profile, unlock material and reachable vaults.</summary>
|
||||
Task<MeResponse> GetMeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
|
||||
Task<EnrollmentResponse> EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
|
||||
/// </summary>
|
||||
@@ -58,7 +76,8 @@ public interface ISyncApi
|
||||
/// Everything else carries a bearer token.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) : ISyncApi
|
||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
||||
: IAccountApi, ISyncApi
|
||||
{
|
||||
private const string MetaPath = "/api/v1/meta";
|
||||
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
|
||||
|
||||
Reference in New Issue
Block a user