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_SendsThisMachineBackToThePassphraseAndClearsTheAccount()
{
await using (var first = await UnlockAsync())
{
await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
}
server.RegisteredDevices.Count.ShouldBe(1);
await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!)
{
var revocation = await second.ForgetDeviceAsync(server, deviceKeys, Token);
revocation.ShouldBe(DeviceRevocation.Complete);
}
var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
outcome.Status.ShouldBe(UnlockStatus.NoDeviceKey);
// The half that used to be left behind. A kind=device wrap is the identity bundle sealed to a key
// somebody may still hold, so a revocation that only cleared this machine left the dangerous part
// exactly where it was.
server.RegisteredDevices.ShouldBeEmpty();
// 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);
}
///
/// Revoking is most wanted at the moment a machine is lost, and being offline is no reason to leave it
/// able to let itself in. The local half is the half that decides whether this machine may unlock — the
/// unlock path never asks the server — so doing it anyway is strictly better than refusing, provided the
/// caller is told the account has not been told.
///
[Fact]
public async Task ForgettingTheDeviceOffline_StillStopsThisMachineAndSaysWhatWasNotDone()
{
await using (var first = await UnlockAsync())
{
await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
}
await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!)
{
var revocation = await second.ForgetDeviceAsync(api: null, deviceKeys, Token);
revocation.ShouldBe(DeviceRevocation.LocalOnly);
}
(await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Status
.ShouldBe(UnlockStatus.NoDeviceKey);
server.RegisteredDevices.Count.ShouldBe(1, "nothing reached the server, and it must not pretend");
}
[Fact]
public async Task ForgettingADeviceThatWasNeverRegistered_SaysSoAndAsksTheServerNothing()
{
await using var session = await UnlockAsync();
var revocation = await session.ForgetDeviceAsync(server, deviceKeys, Token);
revocation.ShouldBe(DeviceRevocation.NothingRegistered);
}
///
/// Registering twice from one machine must not leave two revocable devices behind, which is a property of
/// the id the server issues rather than of the client: the real service is idempotent on the public key
/// and returns the id it already has. Pinned here because a fake that invented a fresh id per call would
/// make every revocation test pass while revoking something that was never registered.
///
[Fact]
public async Task RegisteringTwice_KeepsOneDeviceAndOneIdToRevoke()
{
await using var session = await UnlockAsync();
await session.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
await session.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
server.RegisteredDevices.Count.ShouldBe(1);
var revocation = await session.ForgetDeviceAsync(server, deviceKeys, Token);
revocation.ShouldBe(DeviceRevocation.Complete);
server.RegisteredDevices.ShouldBeEmpty();
}
[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!;
}
}
///