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);
}