using System.Security.Cryptography;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Crypto;
using NSec.Cryptography;
namespace DodoSSH.Client.Session;
/// Why an unlock did or did not produce a session.
public enum UnlockStatus
{
/// Not a legal value.
Unspecified = 0,
/// The vault is open.
Unlocked = 1,
///
/// This machine has never been enrolled, so there is nothing here to unlock. The user has to sign in
/// to a server first, which needs a network.
///
NotEnrolled = 2,
///
/// The passphrase did not open the wrap.
///
///
/// The overwhelmingly common failure, and a return value rather than an exception for that reason.
/// It is also indistinguishable from a tampered wrap, which is correct: the AEAD tag is the only
/// evidence either way, and no passphrase verifier is stored anywhere. See docs/crypto.md §2.
///
WrongPassphrase = 3,
///
/// The identity opened but no vault grant did, so there is nothing readable.
///
///
/// What a rekey looks like before new grants arrive. Distinguished from a wrong passphrase because
/// the remedy is completely different — this one needs a member with Share to finish the rekey, and
/// telling the user to retype their passphrase would be actively misleading.
///
NoReadableVault = 4,
/// The cached KDF parameters are not something this build can use.
UnsupportedKdf = 5,
/// No device key is registered on this machine, so there is nothing to unlock with.
///
/// The ordinary state for a machine nobody has opted in on, and not an error. A caller that offers
/// device unlock should check this before showing a gesture prompt that cannot lead anywhere.
///
NoDeviceKey = 6,
///
/// A device key is registered but this machine would not hand it over.
///
///
/// The user declined the gesture, or the platform invalidated the key — a Hello key does not survive a
/// PIN reset. The two are deliberately not distinguished: the remedy is the passphrase either way, and
/// a message naming which one describes the keystore rather than telling the user anything useful.
///
DeviceKeyUnavailable = 7,
/// The device key was retrieved and did not open the wrap.
///
/// What a rotated identity looks like from a machine whose device wrap predates it. Distinct from
/// because this one will never succeed again — the wrap is for a
/// bundle that no longer exists, and the device has to be registered afresh from an unlocked session.
///
DeviceKeyRejected = 8,
}
/// The result of an unlock attempt.
/// What happened.
/// The open vault, present only when is unlocked.
/// Something to show the user. Never contains secret material.
public sealed record UnlockOutcome(UnlockStatus Status, VaultSession? Session, string Message)
{
/// Whether a session came back.
public bool IsUnlocked => Status == UnlockStatus.Unlocked && Session is not null;
}
///
/// Opens the vault from what is already on this machine.
///
///
///
/// This path touches no network, deliberately and testably. The Argon2id salt, its cost
/// parameters and the wrapped identity bundle are all cached at enrollment, so deriving the master key
/// and opening the bundle need nothing but the passphrase. Fetching any of it at unlock time would make
/// an offline launch impossible, which is the single most common moment a user actually needs their
/// hosts.
///
///
/// Nothing derived here is persisted. The master key exists for the duration of this method and is
/// zeroed before it returns; what survives is the cache subkey and the identity keys, in the session,
/// until the session is disposed.
///
///
public sealed class SessionOpener(
ClientCacheFactory caches,
TimeProvider clock,
SyncOptions? options = null)
{
private readonly SyncOptions options = options ?? SyncOptions.Default;
/// Reads who this machine is enrolled as, without needing a passphrase.
///
/// Lets the unlock screen greet the user by name and show which server they are enrolled against,
/// which is the difference between an unlock prompt and an unexplained password box.
///
public Task ReadProfileAsync(CancellationToken cancellationToken) =>
new UnlockStore(caches, clock).ReadAsync(cancellationToken);
/// Attempts to open the vault.
public async Task UnlockAsync(string passphrase, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(passphrase);
var profile = await ReadProfileAsync(cancellationToken).ConfigureAwait(false);
if (profile is null)
{
return new UnlockOutcome(
UnlockStatus.NotEnrolled,
null,
"This machine is not enrolled yet. Sign in to a DodoSSH server to set it up.");
}
if (!TryReadKdf(profile, out var kdf))
{
return new UnlockOutcome(
UnlockStatus.UnsupportedKdf,
null,
$"The stored key derivation settings ('{profile.KdfParameters.Algorithm}') are not "
+ "supported by this version. Update DodoSSH.");
}
var bundle = OpenBundle(profile, passphrase, kdf, out var protector);
if (bundle is null)
{
return new UnlockOutcome(
UnlockStatus.WrongPassphrase, null, "That passphrase did not open the vault.");
}
try
{
return await BuildSessionAsync(profile, bundle, protector!, cancellationToken)
.ConfigureAwait(false);
}
catch
{
protector!.Dispose();
bundle.Dispose();
throw;
}
}
///
/// Attempts to open the vault with this machine's device key instead of the passphrase.
///
///
///
/// Touches no network, exactly as the passphrase path does not: the device wrap is cached at
/// registration precisely so the one unlock that saves the user typing is not the one that needs to be
/// online. A gesture on a plane is the case this exists for.
///
///
/// Every failure returns rather than throws, and every failure has the same remedy — ask for the
/// passphrase. That is why the caller gets a status and a sentence and not an exception: none of these
/// are exceptional, and a cancelled fingerprint prompt is the most ordinary thing here.
///
///
public async Task UnlockWithDeviceAsync(
IDeviceKeyStore deviceKeys,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(deviceKeys);
var profile = await ReadProfileAsync(cancellationToken).ConfigureAwait(false);
if (profile is null)
{
return new UnlockOutcome(
UnlockStatus.NotEnrolled,
null,
"This machine is not enrolled yet. Sign in to a DodoSSH server to set it up.");
}
if (profile.DeviceWrappedPrivateKey is not { } wrap)
{
return new UnlockOutcome(
UnlockStatus.NoDeviceKey,
null,
"This machine has no device key registered. Unlock with your passphrase.");
}
var material = await deviceKeys.TryLoadAsync(cancellationToken).ConfigureAwait(false);
if (material is null)
{
return new UnlockOutcome(
UnlockStatus.DeviceKeyUnavailable,
null,
"This machine did not release its device key. Unlock with your passphrase.");
}
return await OpenWithDeviceAsync(profile, wrap, material, cancellationToken)
.ConfigureAwait(false);
}
///
/// The raw scalar is zeroed before this returns whatever happens. It came out of a keystore into a
/// managed array, which is the one span of its life nothing else is guarding it.
///
private async Task OpenWithDeviceAsync(
StoredUnlockMaterial profile,
byte[] wrap,
byte[] material,
CancellationToken cancellationToken)
{
UserSecretBundle? bundle;
try
{
bundle = OpenSealedBundle(profile, wrap, material);
}
finally
{
CryptographicOperations.ZeroMemory(material);
}
if (bundle is null)
{
return new UnlockOutcome(
UnlockStatus.DeviceKeyRejected,
null,
"This machine's device key no longer opens the vault. Unlock with your passphrase; the "
+ "device can then be registered again.");
}
var protector = LocalCacheProtector.From(bundle);
try
{
return await BuildSessionAsync(profile, bundle, protector, cancellationToken)
.ConfigureAwait(false);
}
catch
{
protector.Dispose();
bundle.Dispose();
throw;
}
}
///
/// Returns null for a scalar of the wrong length as well as for a wrap that does not open, because a
/// keystore handing back something that is not a key is the same situation from here: this machine
/// cannot unlock and the passphrase can.
///
private static UserSecretBundle? OpenSealedBundle(
StoredUnlockMaterial profile,
byte[] wrap,
byte[] material)
{
if (material.Length != CryptoSpec.SymmetricKeySize)
{
return null;
}
using var deviceKey = Key.Import(
KeyAgreementAlgorithm.X25519,
material,
KeyBlobFormat.RawPrivateKey);
return UserSecretBundle.TryOpenSealed(
deviceKey,
wrap,
DshAad.UserSecretBundle(profile.UserId, profile.KeyGeneration));
}
///
/// The master key lives only inside this method — it opens the bundle and is then done with. The cache
/// protector derives from the bundle rather than from the master key, which is what lets a device or
/// recovery unlock reach the same cache; see .
///
private static UserSecretBundle? OpenBundle(
StoredUnlockMaterial profile,
string passphrase,
Argon2Profile kdf,
out LocalCacheProtector? protector)
{
protector = null;
using var master = MasterKey.Derive(passphrase, profile.KdfParameters.Salt, kdf);
var descriptor = DshAad.UserSecretBundle(profile.UserId, profile.KeyGeneration);
var bundle = master.TryOpenBundle(profile.WrappedPrivateKey, descriptor);
if (bundle is null)
{
return null;
}
try
{
protector = LocalCacheProtector.From(bundle);
return bundle;
}
catch
{
bundle.Dispose();
throw;
}
}
private async Task BuildSessionAsync(
StoredUnlockMaterial profile,
UserSecretBundle bundle,
LocalCacheProtector protector,
CancellationToken cancellationToken)
{
var vaults = await new VaultStore(caches, clock)
.ListAsync(cancellationToken)
.ConfigureAwait(false);
var keyring = VaultKeyring.Open(bundle, vaults);
try
{
var active = vaults.FirstOrDefault(vault => keyring.CanRead(vault.VaultId));
if (active is null)
{
keyring.Dispose();
protector.Dispose();
bundle.Dispose();
return new UnlockOutcome(
UnlockStatus.NoReadableVault,
null,
vaults.Count == 0
? "No vaults are cached on this machine yet. Sign in to synchronise them."
: "Your key does not open any cached vault. It was probably rotated; a member "
+ "with sharing rights needs to re-issue your access.");
}
var session = new VaultSession(
profile, vaults, active.VaultId, bundle, protector, keyring, caches, clock, options);
return new UnlockOutcome(UnlockStatus.Unlocked, session, $"Unlocked '{active.Name}'.");
}
catch
{
keyring.Dispose();
throw;
}
}
///
/// The parameters travel with the wrap so that raising them later is a per-user migration at next
/// unlock rather than a breaking change. The cost of that is having to handle values this build does
/// not recognise, which is what this is: a clear message beats an exception from inside libsodium.
///
private static bool TryReadKdf(StoredUnlockMaterial profile, out Argon2Profile kdf)
{
kdf = Argon2Profile.PassphraseDefault;
if (!string.Equals(profile.KdfParameters.Algorithm, "argon2id", StringComparison.Ordinal))
{
return false;
}
try
{
kdf = Argon2Profile.FromStoredParameters(
profile.KdfParameters.MemoryKibibytes,
profile.KdfParameters.Passes,
profile.KdfParameters.Parallelism);
return true;
}
catch (ArgumentOutOfRangeException)
{
return false;
}
catch (NotSupportedException)
{
return false;
}
}
}