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