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; /// What the server said about this account. public enum ProvisionStatus { /// Not a legal value. Unspecified = 0, /// /// No identity key exists yet. The user must choose a passphrase and enroll before anything else /// works. /// EnrollmentRequired = 1, /// Enrolled, and everything an offline unlock needs is now cached. Ready = 2, } /// The result of talking to the server about this account. /// What happened. /// The profile the server reported. /// /// Present only immediately after enrolling. Must be shown once and never stored. 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. /// /// Something to show the user. public sealed record ProvisionOutcome( ProvisionStatus Status, MeResponse Me, string? RecoveryCode, string Message); /// /// Gets this machine from "signed in" to "has everything an offline unlock needs". /// /// /// /// 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. /// /// /// After enrolling it re-reads /me 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. /// /// public sealed class AccountProvisioner( IAccountApi api, IKeyBindingAuthorizer keyBinding, ClientCacheFactory caches, TimeProvider clock, Argon2Profile? passphraseProfile = null) { /// Reads the account and caches whatever an offline unlock will need. public async Task 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."); } /// /// Creates this account's identity key and personal vault. /// /// The server this profile belongs to. /// The vault passphrase. Never transmitted or stored. /// Human-readable name for this machine, shown in the key statement. /// Display name for the personal vault. /// Cancellation token. /// /// No device key is registered. 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. /// public async Task 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); } } /// /// 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. /// 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, summary.PriorKeyWraps); }