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