Public Access
Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Teams, from the side that holds the keys: create one, add somebody, and wrap a vault key to them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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; <b>sharing is a decision the client
|
||||
/// makes about whether to trust a public key the server just handed it</b>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
ssh,
|
||||
TimeProvider.System);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
deviceKeys,
|
||||
(_, _) => Task.FromResult<IVaultServer>(server),
|
||||
TimeProvider.System,
|
||||
NSubstitute.Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user