using DodoSSH.Client.Session; // FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace; // see the csproj for why it is shared rather than reimplemented. using DodoSSH.Client.Session.Tests; using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; using DodoSSH.Crypto; namespace DodoSSH.Client.App.Tests; /// /// Teams, from the side that holds the keys: create one, add somebody, and wrap a vault key to them. /// /// /// /// The reason this suite exists rather than leaving teams to the server's own tests is that the /// interesting half is not on the server. Adding a member is a row; sharing is a decision the client /// makes about whether to trust a public key the server just handed it, and that decision is what /// stands between an end-to-end encrypted vault and one the operator can read by answering a directory /// lookup with a key of their own. /// /// /// So the fake server keeps a real key log — chained with the same KeyLogChain the server uses — /// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that /// sharing works, not that verification does. /// /// public sealed class TeamSharingTests : IAsyncLifetime { private const string Passphrase = "a sufficiently long passphrase"; private static readonly Argon2Profile CheapProfile = Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); private readonly FakeVaultServer server = new(); private readonly FakeSshConnectionFactory ssh = new(); private string directory = null!; private ClientCacheFactory caches = null!; private TerminalWorkspace workspace = null!; private VaultKnownHostStore knownHosts = null!; private FakeDeviceKeyStore deviceKeys = null!; private MainWindowViewModel shell = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public ValueTask InitializeAsync() { directory = Path.Combine(Path.GetTempPath(), $"dodossh-teams-{Guid.CreateVersion7():N}"); var paths = new ClientPaths(directory); caches = ClientCacheFactory.ForFile(paths.CacheFile); knownHosts = new VaultKnownHostStore(); deviceKeys = new FakeDeviceKeyStore(); workspace = new TerminalWorkspace( new InMemoryTerminalAssetProvider( new Dictionary(StringComparer.Ordinal)), ssh, TimeProvider.System); shell = new MainWindowViewModel( paths, caches, workspace, knownHosts, deviceKeys, (_, _) => Task.FromResult(server), TimeProvider.System, NSubstitute.Substitute.For(), CheapProfile); return ValueTask.CompletedTask; } /// public async ValueTask DisposeAsync() { await shell.DisposeAsync(); knownHosts.Close(); await workspace.DisposeAsync(); caches.Dispose(); try { Directory.Delete(directory, recursive: true); } catch (IOException) { // A cache file the process has not finished releasing. The directory is under the temp path // and named per run, so leaving it costs a few kilobytes and never collides. } } /// /// The whole point of a team, in one test. Note what the status line says after the add and before /// the share: adding somebody grants them nothing readable, and the interface has to say so rather /// than let a user believe the credential is already with their colleague. /// [Fact] public async Task CreatingATeamAndSharingItsVault_WrapsTheKeyToTheOtherMember() { await UnlockedAsync(); var teams = shell.Teams; var colleague = server.AddAccount("bob@example.com", "Bob Example"); await CreateTeamAsync(teams, "Platform", "platform"); await teams.CreateVaultCommand.ExecuteAsync(null); teams.Vaults.Count.ShouldBe(1, teams.Status); teams.InviteEmail = "bob@example.com"; await teams.AddMemberCommand.ExecuteAsync(null); teams.Members.Count.ShouldBe(2, teams.Status); teams.Status.ShouldContain("cannot read anything yet"); teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague); teams.SelectedVault = teams.Vaults[0]; await teams.ShareVaultCommand.ExecuteAsync(null); var vaultId = teams.Vaults[0].VaultId; server.IssuedGrants.ShouldContainKey((vaultId, colleague)); teams.Status.ShouldContain("Shared"); // The one thing verification cannot promise, said in the same breath as the success. teams.Status.ShouldContain("fingerprint", Case.Insensitive); } /// /// /// The test this whole design exists for. A server that wants to read a team's vault only has to /// answer one directory lookup with a key it holds the private half of — so the client reads the /// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was /// offered is in there unchanged. /// /// /// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be /// worse than no check at all, because the interface would have said it was verified. /// /// [Fact] public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt() { await UnlockedAsync(); var teams = shell.Teams; var colleague = server.AddAccount("mallory@example.com", "Mallory Example"); await CreateTeamAsync(teams, "Platform", "platform"); await teams.CreateVaultCommand.ExecuteAsync(null); teams.InviteEmail = "mallory@example.com"; await teams.AddMemberCommand.ExecuteAsync(null); teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague); teams.SelectedVault = teams.Vaults[0]; server.CorruptKeyLog = true; await teams.ShareVaultCommand.ExecuteAsync(null); server.IssuedGrants.ShouldBeEmpty(); teams.Status.ShouldContain("Did not share"); teams.Status.ShouldContain("key log"); } /// /// A vault created here is usable here, without a relock. The key was generated in this process, so /// making the user lock and unlock to reach the vault they just made would be asking them to work /// around bookkeeping. /// [Fact] public async Task ATeamVaultCreatedHere_IsImmediatelyReadableAndWritable() { await UnlockedAsync(); var teams = shell.Teams; await CreateTeamAsync(teams, "Platform", "platform"); await teams.CreateVaultCommand.ExecuteAsync(null); var vaultId = teams.Vaults[0].VaultId; var session = shell.Vault!.Session; session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId); // And it is offered as somewhere to file a new item, which is what makes it worth having. await shell.Vault.LoadAsync(Token); shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId); shell.Vault.HasVaultChoice.ShouldBeTrue(); } /// /// Filing into a team vault has to be chosen and has to stick. The bug this guards is the obvious /// one: an editor that read the picker at save time rather than at open time, so changing the picker /// with a half-typed host on screen would move it. /// [Fact] public async Task AHostFiledIntoATeamVault_StaysThere() { await UnlockedAsync(); var teams = shell.Teams; await CreateTeamAsync(teams, "Platform", "platform"); await teams.CreateVaultCommand.ExecuteAsync(null); var vault = shell.Vault!; var teamVaultId = teams.Vaults[0].VaultId; await vault.LoadAsync(Token); vault.SelectedTargetVault = vault.TargetVaults.Single(choice => choice.VaultId == teamVaultId); vault.NewHostCommand.Execute(null); vault.EditorLabel = "prod-db"; vault.EditorHostname = "db.internal"; vault.EditorUsername = "deploy"; // Moved back after the editor opened. The host must still land in the team's vault. vault.SelectedTargetVault = vault.TargetVaults.First(choice => choice.VaultId != teamVaultId); await vault.SaveHostCommand.ExecuteAsync(null); var row = vault.Hosts.Single( host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal)); row.VaultId.ShouldBe(teamVaultId); } private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug) { await teams.LoadAsync(Token); teams.NewTeamCommand.Execute(null); teams.NewTeamName = name; teams.NewTeamSlug = slug; await teams.CreateTeamCommand.ExecuteAsync(null); teams.SelectedTeam.ShouldNotBeNull(teams.Status); } /// /// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity /// key that was really enrolled: the fake server publishes it into its key log during enrollment, and /// that entry is what the client verifies its own directory answer against. /// private async Task UnlockedAsync() { await shell.StartAsync(Token); await shell.SignInCommand.ExecuteAsync(null); shell.Passphrase = Passphrase; shell.ConfirmPassphrase = Passphrase; await shell.EnrollCommand.ExecuteAsync(null); shell.RecoveryCodeWrittenDown = true; shell.ConfirmRecoveryCodeCommand.Execute(null); shell.Passphrase = Passphrase; await shell.UnlockCommand.ExecuteAsync(null); shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage); } }