using DodoSSH.Client.Domain; using DodoSSH.Client.Storage; using DodoSSH.Crypto; namespace DodoSSH.Client.Session.Tests; /// /// Unlocking with this machine's device key instead of the passphrase. /// /// /// /// The headline test is the offline one: register a device, close the vault, and open it again with no /// server and no passphrase. That is the whole feature, and it is the case a design that fetched the wrap /// at unlock time would have failed. /// /// /// The rest are the ways it is allowed to fail, and every one of them has the same remedy โ€” ask for the /// passphrase. They are separate statuses rather than one because the caller shows a different sentence /// for a machine that was never registered than for a gesture somebody declined. /// /// public sealed class DeviceUnlockTests : IAsyncLifetime { private const string Passphrase = "correct horse battery staple"; private const string ServerUrl = "https://dodossh.example"; private static readonly Argon2Profile CheapProfile = Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); private readonly FakeAccountServer server = new(); private readonly StubKeyBinding keyBinding = new(); private readonly FakeDeviceKeyStore deviceKeys = new(); private ClientCacheFactory caches = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public async ValueTask InitializeAsync() { caches = ClientCacheFactory.ForMemory($"device-{Guid.CreateVersion7():N}"); await caches.MigrateAsync(Token); await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token); } /// public ValueTask DisposeAsync() { caches.Dispose(); return ValueTask.CompletedTask; } [Fact] public async Task ARegisteredDevice_UnlocksWithNoPassphraseAndNoNetwork() { // The feature. Everything else in this file is about the ways it declines to happen. await using (var first = await UnlockAsync()) { (await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token)).ShouldBeTrue(); } var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); outcome.IsUnlocked.ShouldBeTrue(outcome.Message); await using var session = outcome.Session!; session.Profile.UserId.ShouldBe(server.UserId); session.ActiveVaultId.ShouldNotBe(Guid.Empty); } [Fact] public async Task ADeviceUnlock_ReadsTheSameCacheThePassphraseWrote() { // Why the cache key had to move off the master key. A device unlock never computes one, so under the // old derivation this session would have opened the identity and then found its own cache // unreadable โ€” see docs/crypto.md ยง3.2. Guid hostId; await using (var first = await UnlockAsync()) { await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); hostId = await first.Hosts.CreateAsync( first.ActiveVaultId, new HostSecret { Label = "db", Hostname = "db.internal", Username = "deploy" }, Token); } var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); outcome.IsUnlocked.ShouldBeTrue(outcome.Message); await using var session = outcome.Session!; var hosts = await session.Hosts.ListAsync(session.ActiveVaultId, Token); hosts.Items.ShouldContain(host => host.EntityId == hostId); } [Fact] public async Task WithNoDeviceRegistered_ItSaysSoRatherThanFailing() { // The ordinary state of a machine nobody has opted in on. A caller checks this before showing a // gesture prompt that could not lead anywhere. var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); outcome.Status.ShouldBe(UnlockStatus.NoDeviceKey); outcome.Session.ShouldBeNull(); } [Fact] public async Task WhenTheGestureIsDeclined_ItAsksForThePassphrase() { await using (var first = await UnlockAsync()) { await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); } // What a cancelled fingerprint prompt looks like from here, and what a Hello key invalidated by a // PIN reset looks like too. Deliberately the same status: the remedy does not differ. deviceKeys.Decline = true; var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); outcome.Status.ShouldBe(UnlockStatus.DeviceKeyUnavailable); outcome.Session.ShouldBeNull(); } [Fact] public async Task WhenTheStoredKeyDoesNotOpenTheWrap_ItIsRejectedRatherThanRetried() { await using (var first = await UnlockAsync()) { await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); } // A different key of the right length: what a rotated identity leaves behind on a machine whose // wrap predates it. Distinct from a declined gesture because this one will never succeed again. deviceKeys.Overwrite(new byte[CryptoSpec.SymmetricKeySize]); var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); outcome.Status.ShouldBe(UnlockStatus.DeviceKeyRejected); outcome.Session.ShouldBeNull(); } [Fact] public async Task WhenTheKeystoreReturnsSomethingThatIsNotAKey_ItIsRefusedNotThrown() { // A keystore handing back the wrong number of bytes is a broken keystore, and the answer is still a // passphrase prompt rather than a crash on the unlock screen. await using (var first = await UnlockAsync()) { await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); } deviceKeys.Overwrite([1, 2, 3]); var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); outcome.Status.ShouldBe(UnlockStatus.DeviceKeyRejected); } [Fact] public async Task OnAMachineWithNoKeystore_RegisteringDeclinesAndRegistersNothing() { // Registering a device whose private half does not survive the process would put a wrap on the // server that nothing can open, and make the account claim a capability this machine lacks. await using var session = await UnlockAsync(); var registered = await session.RegisterDeviceAsync( server, new UnavailableDeviceKeyStore(), "this laptop", Token); registered.ShouldBeFalse(); server.RegisteredDevices.ShouldBeEmpty(); } [Fact] public async Task ForgettingTheDevice_SendsThisMachineBackToThePassphrase() { await using (var first = await UnlockAsync()) { await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); } await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!) { await second.ForgetDeviceAsync(deviceKeys, Token); } var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); outcome.Status.ShouldBe(UnlockStatus.NoDeviceKey); // And the passphrase still works, which is the property that makes forgetting safe to offer. await using var byPassphrase = await UnlockAsync(); byPassphrase.ActiveVaultId.ShouldNotBe(Guid.Empty); } [Fact] public async Task RefreshingTheProfile_DoesNotDiscardTheDeviceWrap() { // The trap in UnlockStore.Apply. /me is re-read on every sign-in and knows nothing about this // machine's keystore, so writing its device columns unconditionally would delete the wrap on the // next launch โ€” and the user's fingerprint would stop working for no visible reason. await using (var first = await UnlockAsync()) { await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); } await Provisioner().RefreshAsync(ServerUrl, Token); var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); outcome.IsUnlocked.ShouldBeTrue(outcome.Message); await outcome.Session!.DisposeAsync(); } [Fact] public async Task TheWrapReachesTheServerAndTheKeyDoesNot() { // The division the whole design rests on: the server stores a sealed bundle it cannot open, and the // private half never leaves this machine. await using var session = await UnlockAsync(); await session.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); server.RegisteredDevices.Count.ShouldBe(1); var stored = deviceKeys.Peek().ShouldNotBeNull(); stored.Length.ShouldBe(CryptoSpec.SymmetricKeySize); // Nothing the server holds contains the private scalar. foreach (var wrap in server.RegisteredDevices.Values) { Convert.ToHexString(wrap).ShouldNotContain( Convert.ToHexString(stored), Case.Insensitive); } } // ---- Helpers ---- private SessionOpener Opener() => new(caches, TimeProvider.System); private AccountProvisioner Provisioner() => new(server, keyBinding, caches, TimeProvider.System, CheapProfile); private async Task UnlockAsync() { var outcome = await Opener().UnlockAsync(Passphrase, Token); outcome.IsUnlocked.ShouldBeTrue(outcome.Message); return outcome.Session!; } } /// /// A device key store that keeps its key in a field. /// /// /// Stands in for whatever guards the key on a real machine. The gesture is the entire security value of the /// real thing, so what this fake models is the two ways the gesture ends: it hands the key over, or it does /// not. is a cancelled prompt and an invalidated key at once, which is exactly how much /// the caller is allowed to know. /// internal sealed class FakeDeviceKeyStore : IDeviceKeyStore { private byte[]? key; /// When set, the next load refuses, as a cancelled gesture does. internal bool Decline { get; set; } /// Whether this machine can keep a key at all. internal bool IsAvailable { get; set; } = true; /// Reads the stored key without a gesture, for assertions only. internal byte[]? Peek() => key; /// Replaces the stored key, standing in for a rotated or corrupted keystore entry. internal void Overwrite(byte[] replacement) => key = replacement; /// public ValueTask IsAvailableAsync(CancellationToken cancellationToken) => ValueTask.FromResult(IsAvailable); /// public ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken) { key = devicePrivateKey.ToArray(); return ValueTask.CompletedTask; } /// public ValueTask TryLoadAsync(CancellationToken cancellationToken) => ValueTask.FromResult(Decline ? null : key?.ToArray()); /// public ValueTask ForgetAsync(CancellationToken cancellationToken) { key = null; return ValueTask.CompletedTask; } }