Files
DodoSSH/src/DodoSSH.Client.Api/ClientEnrollment.cs
T
jaap-jan 49f617b450 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.
2026-07-29 11:02:19 +02:00

343 lines
14 KiB
C#

using System.Security.Cryptography;
using DodoSSH.Client.Auth;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using NSec.Cryptography;
namespace DodoSSH.Client.Api;
/// <summary>What enrolling produced, for the caller to hold and persist.</summary>
/// <remarks>
/// The bundle and the vault key are live secrets. The caller owns their lifetime and must dispose the
/// bundle; neither is ever written anywhere but the OS keystore and the encrypted local cache.
/// </remarks>
/// <param name="Response">The server's answer, including the vault and key log position.</param>
/// <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, 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
/// along with the passphrase and every device means the vault is unrecoverable, and no server-side
/// reset is possible by design.
/// </param>
public sealed record EnrollmentOutcome(
EnrollmentResponse Response,
UserSecretBundle Bundle,
byte[] PersonalVaultKey,
byte[]? DevicePrivateKey,
string RecoveryCode);
/// <summary>
/// Runs enrollment: generate keys, have the identity provider sign over them, and publish.
/// </summary>
/// <remarks>
/// <para>
/// Ordering here is not a matter of taste. The secret bundle's AAD binds to the server-assigned user
/// id, so <c>/me</c> must be read before anything can be wrapped — which is why <c>/me</c> provisions
/// the account and returns its id even when it reports that enrollment is required.
/// </para>
/// <para>
/// Everything the server receives is opaque to it. It gets public keys, wrapped blobs it cannot open,
/// and signatures it does not verify beyond the statement's own. That is the whole point: the server
/// stores the vault and cannot read it.
/// </para>
/// </remarks>
public sealed class ClientEnrollment(
IAccountApi api,
IKeyBindingAuthorizer keyBinding,
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;
/// <summary>
/// Enrolls the caller.
/// </summary>
/// <param name="me">
/// The result of <see cref="DodoSshApiClient.GetMeAsync"/>, which supplies the user id the wraps
/// bind to.
/// </param>
/// <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);
ArgumentException.ThrowIfNullOrWhiteSpace(passphrase);
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
var now = clock.GetUtcNow();
var bundle = UserSecretBundle.Create(now);
try
{
var statement = BuildStatement(me, bundle, now, deviceName);
// The provider signs over the statement's hash, which is what stops the DodoSSH server
// fabricating a key for a user who never enrolled. See ADR 0001.
var idToken = await keyBinding
.AuthorizeKeyBindingAsync(
KeyStatementCodec.ComputeNonce(ToFields(statement)),
cancellationToken)
.ConfigureAwait(false);
var request = BuildRequest(
me, bundle, statement, idToken, passphrase, passphraseProfile, vaultName,
bindThisDevice, now, out var material);
var response = await api.EnrollAsync(request, cancellationToken).ConfigureAwait(false);
return new EnrollmentOutcome(
response,
bundle,
material.VaultKey,
material.DevicePrivateKey,
material.RecoveryCode);
}
catch
{
// The caller never receives the bundle on failure, so this is the only place that can
// release its guarded memory.
bundle.Dispose();
throw;
}
}
private static KeyStatement BuildStatement(
MeResponse me,
UserSecretBundle bundle,
DateTimeOffset now,
string deviceName) =>
new(
Version: KeyStatementCodec.CurrentVersion,
Issuer: me.Issuer,
Subject: me.Subject,
Email: me.Email,
EncryptionPublicKey: bundle.EncryptionPublicKey,
SigningPublicKey: bundle.SigningPublicKey,
KeyGeneration: 1,
CreatedAt: now,
DeviceName: deviceName);
/// <summary>Secrets the caller keeps after a successful enrollment.</summary>
private readonly record struct SessionMaterial(
byte[] VaultKey,
byte[]? DevicePrivateKey,
string RecoveryCode);
/// <remarks>
/// The passphrase is a parameter rather than a field, so it lives only for the duration of this call
/// and never becomes state on a long-lived object that a heap dump would find.
/// </remarks>
private static EnrollmentRequest BuildRequest(
MeResponse me,
UserSecretBundle bundle,
KeyStatement statement,
string idToken,
string passphrase,
Argon2Profile passphraseProfile,
string vaultName,
bool bindThisDevice,
DateTimeOffset now,
out SessionMaterial material)
{
var descriptor = DshAad.UserSecretBundle(me.UserId);
// A fresh salt per wrap, and the parameters travel with it — so raising the cost later is a
// per-user migration at next unlock rather than a breaking change.
var passphraseSalt = RandomNumberGenerator.GetBytes(CryptoSpec.SaltSize);
var recoverySalt = RandomNumberGenerator.GetBytes(CryptoSpec.SaltSize);
var recoveryCode = GenerateRecoveryCode();
byte[] passphraseWrap;
byte[] recoveryWrap;
using (var master = MasterKey.Derive(passphrase, passphraseSalt, passphraseProfile))
{
passphraseWrap = master.WrapBundle(bundle, descriptor);
}
// The recovery code carries real entropy, so it needs far less stretching than a passphrase.
using (var recoveryMaster = MasterKey.Derive(
recoveryCode, recoverySalt, Argon2Profile.RandomSecret))
{
recoveryWrap = recoveryMaster.WrapBundle(bundle, descriptor);
}
byte[]? devicePublicKey = null;
byte[]? deviceWrap = null;
byte[]? devicePrivateKey = null;
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, devicePrivateKey, recoveryCode);
return new EnrollmentRequest(
Statement: statement,
StatementSignature: DshSignatures.SignKeyStatement(
bundle.SigningKey,
KeyStatementCodec.Encode(ToFields(statement))),
IdentityProviderToken: idToken,
WrappedPrivateKey: passphraseWrap,
KdfParameters: ToContract(passphraseSalt, passphraseProfile),
DevicePublicKey: devicePublicKey,
DeviceWrappedPrivateKey: deviceWrap,
RecoveryWrappedPrivateKey: recoveryWrap,
RecoveryKdfParameters: ToContract(recoverySalt, Argon2Profile.RandomSecret),
PersonalVault: vault);
}
/// <remarks>
/// The vault id is chosen here rather than by the server, which is what makes enrollment safely
/// retryable and is required by the grant signature — the signed tuple covers the vault id.
/// <para>
/// A self-grant carries no key log head: there is no third party whose key could have been
/// substituted, and the log entry that would supply one is written by the server in the same
/// transaction, so it cannot be signed over here.
/// </para>
/// </remarks>
private static PersonalVaultRequest BuildPersonalVault(
MeResponse me,
UserSecretBundle bundle,
string vaultName,
DateTimeOffset now,
out byte[] vaultKey)
{
var vaultId = Guid.CreateVersion7();
vaultKey = VaultKeys.Create();
var wrappedVaultKey = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1);
var fingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey,
bundle.SigningPublicKey);
var grant = GrantStatementCodec.Encode(
vaultId,
keyGeneration: 1,
GrantPurpose.Member,
granteeUserId: me.UserId,
granteeKeyFingerprint: fingerprint,
wrappedKey: wrappedVaultKey,
granterUserId: me.UserId,
granterKeyFingerprint: fingerprint,
keyLogHead: default,
grantedAt: now);
return new PersonalVaultRequest(
VaultId: vaultId,
Name: vaultName,
WrappedVaultKey: wrappedVaultKey,
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, grant),
GrantedAt: now);
}
/// <summary>
/// Generates a printable recovery code.
/// </summary>
/// <remarks>
/// Base32 over Crockford's alphabet, which omits I, L, O and U — so a code read aloud or copied off
/// a screen cannot be mistranscribed into a different valid code, and cannot spell anything
/// unfortunate. Grouped for legibility, and the groups are not part of the secret: the derivation
/// uses the string exactly as shown, dashes included, because that is what the user will type back.
/// </remarks>
private static string GenerateRecoveryCode()
{
const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const int BitsPerCharacter = 5;
const int CharactersPerGroup = 5;
// 20 bytes is 160 bits, which divides evenly into 32 five-bit characters — so no bits are
// discarded and no padding is needed. Separators go between groups, hence one fewer than the
// number of groups.
var totalCharacters = RecoveryEntropyBytes * 8 / BitsPerCharacter;
var separators = (totalCharacters - 1) / CharactersPerGroup;
var entropy = RandomNumberGenerator.GetBytes(RecoveryEntropyBytes);
var characters = new char[totalCharacters + separators];
var index = 0;
for (var position = 0; position < totalCharacters; position++)
{
if (position > 0 && position % CharactersPerGroup == 0)
{
characters[index++] = '-';
}
var value = 0;
for (var offset = 0; offset < BitsPerCharacter; offset++)
{
var bit = (position * BitsPerCharacter) + offset;
value = (value << 1) | ((entropy[bit / 8] >> (7 - (bit % 8))) & 1);
}
characters[index++] = Alphabet[value];
}
CryptographicOperations.ZeroMemory(entropy);
return new string(characters);
}
private static KdfParameters ToContract(byte[] salt, Argon2Profile profile) =>
new(
Algorithm: "argon2id",
Salt: salt,
MemoryKibibytes: profile.MemoryKibibytes,
Passes: profile.Passes,
Parallelism: Argon2Profile.Parallelism);
private static KeyStatementFields ToFields(KeyStatement statement) =>
new(
statement.Version,
statement.Issuer,
statement.Subject,
statement.Email,
statement.EncryptionPublicKey,
statement.SigningPublicKey,
statement.KeyGeneration,
statement.CreatedAt,
statement.DeviceName);
}