using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Session.Tests;
///
/// Enrolling once, then unlocking with nothing but a passphrase and a file.
///
///
/// The headline property here is that the second half needs no server at all. That is asserted directly:
/// every unlock in this suite runs against a that has never been given a
/// transport and could not reach one if it wanted to.
///
public sealed class SessionLifecycleTests : IAsyncLifetime
{
private const string Passphrase = "correct horse battery staple";
private const string ServerUrl = "https://dodossh.example";
///
/// Far below the shipped 256 MiB profile. The stretching is what makes a stolen wrap expensive to
/// attack and none of these tests attack one; paying a third of a second per derivation — and there
/// are three per enroll-and-unlock cycle — would only encourage sharing state between tests.
///
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 ClientCacheFactory caches = null!;
///
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"session-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(TestContext.Current.CancellationToken);
}
///
public ValueTask DisposeAsync()
{
caches.Dispose();
return ValueTask.CompletedTask;
}
[Fact]
public async Task AFreshMachine_HasNothingToUnlock()
{
(await Opener().ReadProfileAsync(Token)).ShouldBeNull();
var outcome = await Opener().UnlockAsync(Passphrase, Token);
outcome.Status.ShouldBe(UnlockStatus.NotEnrolled);
outcome.Session.ShouldBeNull();
outcome.Message.ShouldContain("not enrolled");
}
[Fact]
public async Task EnrollingLeavesEverythingAnOfflineUnlockNeeds()
{
// The property the whole storage layer exists for. After this point the passphrase alone opens
// the vault: no salt is fetched, no grant is fetched, nothing is asked of a server.
var provision = await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
provision.Status.ShouldBe(ProvisionStatus.Ready);
provision.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
await using var session = await UnlockAsync();
session.Profile.UserId.ShouldBe(server.UserId);
session.Profile.ServerUrl.ShouldBe(ServerUrl);
session.Profile.Issuer.ShouldBe(FakeAccountServer.Issuer);
session.Vaults.ShouldHaveSingleItem().Name.ShouldBe("Personal");
session.UnreadableVaults.ShouldBeEmpty();
}
[Fact]
public async Task TheProfileCanBeReadWithoutThePassphrase()
{
// So the unlock screen can say who it is asking, rather than showing an unexplained password box.
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var profile = await Opener().ReadProfileAsync(Token);
profile.ShouldNotBeNull();
profile.Email.ShouldBe("alice@example.com");
profile.DisplayName.ShouldBe("Alice");
profile.ServerUrl.ShouldBe(ServerUrl);
}
[Fact]
public async Task TheWrongPassphrase_IsAnAnswerRatherThanAnException()
{
// The overwhelmingly common failure. It is also indistinguishable from a tampered wrap, which is
// correct: the AEAD tag is the only evidence either way and no verifier is stored anywhere.
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var outcome = await Opener().UnlockAsync("not the passphrase", Token);
outcome.Status.ShouldBe(UnlockStatus.WrongPassphrase);
outcome.Session.ShouldBeNull();
}
[Fact]
public async Task NoDeviceKeyIsRegistered()
{
// A device wrap whose private half has nowhere to live is a row nobody can ever open, and it would
// make the account's device list claim this machine can unlock without a passphrase. Until the OS
// keystore is wired, not offering it is the honest answer.
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var request = server.LastEnrollment.ShouldNotBeNull();
request.DevicePublicKey.ShouldBeNull();
request.DeviceWrappedPrivateKey.ShouldBeNull();
// The recovery wrap is still registered: it is the only route back if the passphrase is lost.
request.RecoveryWrappedPrivateKey.ShouldNotBeNull();
request.RecoveryKdfParameters.ShouldNotBeNull();
}
[Fact]
public async Task AnAlreadyEnrolledAccount_IsNotEnrolledAgain()
{
// Re-enrolling would replace an identity key that other members may already have wrapped vault
// keys to, which is a far worse outcome than asking for the existing passphrase.
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var second = await Provisioner().EnrollAsync(ServerUrl, "a different one", "desktop", "Personal", Token);
second.Status.ShouldBe(ProvisionStatus.Ready);
second.RecoveryCode.ShouldBeNull();
server.EnrollmentCount.ShouldBe(1);
// And the original passphrase still works, because nothing was replaced.
await using var session = await UnlockAsync();
session.Vaults.ShouldHaveSingleItem();
}
[Fact]
public async Task SigningInToAnUnenrolledAccount_AsksForEnrollmentRatherThanFailing()
{
var outcome = await Provisioner().RefreshAsync(ServerUrl, Token);
outcome.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
outcome.Me.EnrollmentRequired.ShouldBeTrue();
// Nothing was cached, so an unlock still reports honestly.
(await Opener().ReadProfileAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task RefreshingAnEnrolledAccount_RepairsACacheThatLostItsVaults()
{
// What signing in on a machine whose cache was cleared looks like. The material comes back from
// the server, and the passphrase — which the server never had — opens it again.
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
using var replacement = ClientCacheFactory.ForMemory($"session-{Guid.CreateVersion7():N}");
await replacement.MigrateAsync(Token);
var outcome = await new AccountProvisioner(
server, keyBinding, replacement, TimeProvider.System, CheapProfile)
.RefreshAsync(ServerUrl, Token);
outcome.Status.ShouldBe(ProvisionStatus.Ready);
var unlocked = await new SessionOpener(replacement, TimeProvider.System)
.UnlockAsync(Passphrase, Token);
unlocked.IsUnlocked.ShouldBeTrue(unlocked.Message);
await unlocked.Session!.DisposeAsync();
}
[Fact]
public async Task AVaultWhoseGrantWasRevoked_SaysSoRatherThanLookingEmpty()
{
// A rekey this client has not been re-issued for. Reporting a wrong passphrase here would send the
// user to retype something that was never the problem.
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
server.RevokeVaultGrant();
await Provisioner().RefreshAsync(ServerUrl, Token);
var outcome = await Opener().UnlockAsync(Passphrase, Token);
outcome.Status.ShouldBe(UnlockStatus.NoReadableVault);
outcome.Message.ShouldContain("rotated");
}
[Fact]
public async Task AnUnsupportedKdf_IsNamedRatherThanThrowingFromLibsodium()
{
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var profile = (await Opener().ReadProfileAsync(Token)).ShouldNotBeNull();
await new UnlockStore(caches, TimeProvider.System).SaveAsync(
profile with
{
KdfParameters = profile.KdfParameters with { Algorithm = "argon2-from-the-future" },
},
Token);
var outcome = await Opener().UnlockAsync(Passphrase, Token);
outcome.Status.ShouldBe(UnlockStatus.UnsupportedKdf);
outcome.Message.ShouldContain("argon2-from-the-future");
}
[Fact]
public async Task AnUnlockedSession_ReadsAndWritesHostsWithNoServer()
{
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
await using var session = await UnlockAsync();
var entityId = await session.Hosts.CreateAsync(
session.ActiveVaultId, Host("prod-db"), Token);
var listing = await session.Hosts.ListAsync(session.ActiveVaultId, Token);
var host = listing.Items.ShouldHaveSingleItem();
host.EntityId.ShouldBe(entityId);
host.Secret.Label.ShouldBe("prod-db");
host.HasUnsyncedChanges.ShouldBeTrue();
(await session.PendingChangeCountAsync(Token)).ShouldBe(1);
}
[Fact]
public async Task ASessionSyncsThroughWhicheverTransportItIsHanded()
{
// The session deliberately holds no transport: losing the network invalidates the connection, not
// the vault.
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
await using var session = await UnlockAsync();
await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token);
var transport = new EmptySyncApi();
var report = await session.SyncAsync(transport, session.ActiveVaultId, Token);
// Two operations for one host: the host, and the activity log entry recording that somebody created
// it. PushedItems is the number that means "the user's own work", and it is one — see
// SyncReport.PushedItems for why the two are counted apart.
report.Pushed.ShouldBe(2);
report.PushedItems.ShouldBe(1);
report.PushedLogEntries.ShouldBe(1);
transport.PushCount.ShouldBe(1, "both go in one batch");
// Zero for the same reason: log entries are not somebody's work waiting to be made safe.
(await session.PendingChangeCountAsync(Token)).ShouldBe(0);
}
[Fact]
public async Task ADisposedSession_RefusesToBeUsed()
{
// Locking is disposing, so this is what "locked" has to mean in practice.
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var session = await UnlockAsync();
var vaultId = session.ActiveVaultId;
await session.DisposeAsync();
await Should.ThrowAsync(
async () => await session.ReadConflictsAsync(Token));
await Should.ThrowAsync(
async () => await session.Hosts.ListAsync(vaultId, Token));
// Idempotent, because shutdown paths call it more than once.
await session.DisposeAsync();
}
[Fact]
public async Task ASecondUnlock_ProducesAnIndependentSession()
{
// Two windows, or a lock followed by an unlock. Disposing one must not take the other's keys with
// it, which it would if anything here were shared statically.
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var first = await UnlockAsync();
await using var second = await UnlockAsync();
await first.DisposeAsync();
var listing = await second.Hosts.ListAsync(second.ActiveVaultId, Token);
listing.Unreadable.ShouldBe(0);
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
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!;
}
private static HostSecret Host(string label) =>
new()
{
Label = label,
Hostname = "db.internal",
Port = 22,
Username = "deploy",
};
}