using System.Text;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.SystemTests;
///
/// M1's definition of done: sign in, enroll, unlock, create a host, sync, read it on a second machine,
/// and open a shell on it.
///
///
///
/// Nothing is stubbed. A real Keycloak issues the tokens and signs the key binding, a real API stores the
/// ciphertext in a real PostgreSQL, real DSH1 crypto seals and opens it, and a real sshd answers at
/// the end. Every other suite substitutes at least one of those, and each substitution is a place where a
/// misreading of the protocol can be consistent on both sides and still wrong in production — which is
/// exactly what this found the first time it ran.
///
///
/// One test rather than several, because the steps are not independent: you cannot unlock without having
/// enrolled, and enrollment happens once per account. Splitting them would mean sharing mutable state
/// between tests or repeating a minute of setup per assertion.
///
///
public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture, IAsyncDisposable
{
private const string Passphrase = "an end to end passphrase";
///
/// 64 MiB is the floor EnrollmentLimits enforces, and this suite has to respect it — the other
/// client suites use 8 MiB because their in-memory servers have no policy, and a real one rejects that
/// outright. Worth knowing rather than discovering: the reduction those suites take for speed is only
/// available because nothing is checking, and the difference is a 400 rather than a slow test.
///
private static readonly Argon2Profile ServerFloorProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 64 * 1024, passes: 3, parallelism: 1);
private readonly List directories = [];
///
public ValueTask DisposeAsync()
{
foreach (var directory in directories.Where(Directory.Exists))
{
Directory.Delete(directory, recursive: true);
}
return ValueTask.CompletedTask;
}
[Fact]
public async Task TheWholeSlice()
{
// The realm file's own account, deliberately — see DevStack.RealmUser. A runtime-minted one hid a
// sign-in failure that only the committed configuration had.
var account = DevStack.RealmUser;
var browser = new ScriptedBrowser(account.Username, account.Password);
// The plaintext exemption is stated here rather than inferred from the address, and that is the
// whole point of stating it. ServerConnection allows an http authority only when it is loopback,
// which is a sound rule and not one this suite can rely on: Testcontainers reports the host it
// can actually be reached at, so a developer running the tests directly gets localhost and passes,
// while the same suite inside a container gets the bridge gateway — 172.17.0.1 — and is refused.
// That is the product being right. 172.17.0.1 is not loopback, and a client that quietly accepted
// plaintext metadata from a routable address would be a real weakness for everyone who is not a
// test. So the test says out loud that it accepts plaintext from the throwaway Keycloak it started
// itself, and the rule stays as strict as it was for everybody else.
using var connection = await ServerConnection.SignInAsync(
stack.ApiBaseUrl,
browser,
TimeProvider.System,
Token,
configureOidc: options => options with { RequireHttpsMetadata = false });
AssertDiscoveredFromTheServer(connection);
using var laptopCache = await OpenCacheAsync();
await EnrollAsync(connection, laptopCache, browser);
var laptop = await UnlockAsync(laptopCache);
await using var laptopSession = laptop;
// The key first, because the host binds it. A second item type in the same vault and the same
// outbox is what makes this a test of the shared write path rather than of hosts: the server picks a
// table per type, the client picks a cipher per type, and the AAD binds a different resource type
// into each. All three are hand-kept mappings between enums that do not line up, and a swap between
// them encrypts, decrypts and stores perfectly on the machine that made it.
var key = BuildKey();
var keyId = await laptop.SshKeys.CreateAsync(laptop.ActiveVaultId, key, Token);
// Bound to the key, which also makes this host a schema-version-2 payload — so the slice covers a
// payload written at a version older clients will refuse to edit, through the real server.
var host = BuildHost(keyId);
var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token);
var pushed = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
AssertTheKeyAndTheHostWentUpWithTheirLogEntries(pushed);
await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
await AssertTheServerLearnsNothingAboutTheKeyAsync(connection, keyId);
// The shell, and the trust decision it produces. Before the second machine reads the vault, so that
// what the second machine pulls includes the host key this one approved — which is the claim the whole
// item type exists to make and the only place it is proved through a real server.
var pin = await OpenAShellAsync(laptop, host);
var trusted = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
trusted.PushedItems.ShouldBe(1, "the host key the user approved at the prompt");
// And its activity entry. Worth asserting rather than ignoring: a pin is written programmatically at
// connect time and never through a screen, which is exactly the write an activity hook placed in the
// view models would have missed — see IActivityLogSink.
trusted.PushedLogEntries.ShouldBe(1);
trusted.NeedsAttention.ShouldBeFalse();
await AssertTheServerLearnsNothingAboutTheTrustedHostAsync(connection);
await ReadOnASecondMachineAsync(connection, host, entityId, key, keyId, pin);
await AssertUnlocksOfflineAsync(laptopCache);
}
// ---- Steps ----
///
/// The user typed one server URL. Everything about the identity provider — the authority, the client
/// id, the scopes — came back from the server, which is the whole onboarding story.
///
private void AssertDiscoveredFromTheServer(ServerConnection connection)
{
connection.Configuration.Oidc.Authority.ToString()
.ShouldStartWith(stack.Authority.ToString());
connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop");
// Server:PublicBaseUrl, which is what a client behind a proxy would follow. Worth asserting
// because it is configuration the server states about itself and nothing else would notice it
// being wrong.
connection.Configuration.ApiBaseUrl.ShouldBe(stack.ApiBaseUrl);
connection.Meta.SyncProtocolVersion.ShouldBe(1);
connection.Meta.CryptoSpecVersion.ShouldBe(1);
}
private async Task EnrollAsync(
ServerConnection connection,
ClientCacheFactory caches,
ScriptedBrowser browser)
{
var provisioner = new AccountProvisioner(
connection.Account, connection.KeyBinding, caches, TimeProvider.System, ServerFloorProfile);
var before = await provisioner.RefreshAsync(ServerUrl, Token);
before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
var enrolled = await provisioner.EnrollAsync(
ServerUrl, Passphrase, "e2e-laptop", "Personal", Token);
enrolled.Status.ShouldBe(ProvisionStatus.Ready);
enrolled.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
// Two sign-ins, not one. The second is the identity-provider key binding: an authorization whose
// nonce is the key statement's hash, whose ID token the server verified against Keycloak's JWKS
// before accepting the key. That is what stops a compromised DodoSSH server fabricating a key for
// someone who never enrolled — see ADR 0001 — and it is invisible unless something counts.
browser.SignInCount.ShouldBe(
2, "enrollment must obtain an identity-provider signature over the published key");
}
///
/// Asserted against what the server hands back, not against the local mirror. With relay off the
/// address stays inside the ciphertext; ADR 0004 is the only reason it would ever be otherwise.
///
///
/// Two items and two activity entries, through the real server.
///
///
/// Creating a key and creating a host are each recorded, and the entries go up in the same batch as the
/// items they are about. PushedItems is the number this assertion was originally written about —
/// the user's own work — and the log entries are counted apart precisely so that number goes on meaning
/// what it meant before there were any.
///
private static void AssertTheKeyAndTheHostWentUpWithTheirLogEntries(SyncReport pushed)
{
pushed.PushedItems.ShouldBe(2);
pushed.PushedLogEntries.ShouldBe(2);
pushed.Pushed.ShouldBe(4);
pushed.NeedsAttention.ShouldBeFalse();
}
private static async Task AssertTheServerCannotSeeTheAddressAsync(
ServerConnection connection,
Guid entityId)
{
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
var page = await connection.Sync.SyncPullAsync(
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.Host]), Token);
var change = page.Changes.Single(c => c.EntityId == entityId);
change.PlaintextFields.ShouldNotBeNull();
change.PlaintextFields.RelayEnabled.ShouldBeFalse();
change.PlaintextFields.Hostname.ShouldBeNull("the address must not leave the payload");
change.PlaintextFields.Port.ShouldBeNull();
// What it does hold is opaque, and it carries its data key as the specification requires.
change.Payload.ShouldNotBeNull();
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
}
///
/// The relay concession is the host's alone. A key has no address to resolve, so the server is given
/// nothing at all about it — not even the public-key fingerprint its own schema has a column for, which
/// it would have accepted. A fingerprint is not secret but it is a stable identifier for a key pair, and
/// nothing in the product reads that column; see the note on SshKeyKind.Fields.
///
private static async Task AssertTheServerLearnsNothingAboutTheKeyAsync(
ServerConnection connection,
Guid keyId)
{
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
var page = await connection.Sync.SyncPullAsync(
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.SshKey]), Token);
// Asked for keys, and got only keys back — so the filter the client relies on is honoured by the
// real endpoint and not merely by the in-memory one the unit suites use.
page.Changes.ShouldAllBe(change => change.EntityType == SyncEntityType.SshKey);
var change = page.Changes.Single(c => c.EntityId == keyId);
change.PlaintextFields.ShouldBeNull(
"a key gives the server no plaintext columns, so it hydrates to nothing at all");
change.Payload.ShouldNotBeNull();
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
}
///
/// Takes the host key presentation the shell step produced, because the point of pinning trust in the
/// vault is that this machine — which has never spoken to that sshd — already knows the fingerprint
/// the other one approved.
///
private async Task ReadOnASecondMachineAsync(
ServerConnection connection,
HostSecret expected,
Guid entityId,
SshKeySecret expectedKey,
Guid keyId,
HostKeyPresentation pin)
{
using var desktopCache = await OpenCacheAsync();
var provisioner = new AccountProvisioner(
connection.Account, connection.KeyBinding, desktopCache, TimeProvider.System, ServerFloorProfile);
// Already enrolled, so this only caches what an offline unlock will need.
(await provisioner.RefreshAsync(ServerUrl, Token)).Status
.ShouldBe(ProvisionStatus.Ready);
var desktop = await UnlockAsync(desktopCache);
await using var session = desktop;
var pulled = await desktop.SyncAsync(connection.Sync, desktop.ActiveVaultId, Token);
pulled.PulledItems.ShouldBe(3, "the host, the key and the approved host key, in one pass");
// And the three activity entries the first machine wrote about them, which is the claim the log
// exists to make: what somebody did on one machine is readable on another. Once teams land it is an
// administrator reading it rather than the same person, and nothing else about it changes.
pulled.PulledLogEntries.ShouldBe(3);
var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token);
var seen = listing.Items.ShouldHaveSingleItem();
seen.EntityId.ShouldBe(entityId);
seen.HasUnsyncedChanges.ShouldBeFalse();
// The decrypted host survived a round trip through a server that could read none of it — including
// the directives, which merge per name and therefore have to come back in canonical form.
seen.Secret.ShouldBe(expected);
var keys = await desktop.SshKeys.ListAsync(desktop.ActiveVaultId, Token);
var seenKey = keys.Items.ShouldHaveSingleItem();
seenKey.EntityId.ShouldBe(keyId);
seenKey.HasUnsyncedChanges.ShouldBeFalse();
// Including the private key itself, byte for byte and unreformatted, and the passphrase stored with
// it. This is the whole promise of a shared vault holding a key: a second machine can use it without
// the key ever having been readable to the thing that carried it.
seenKey.Secret.ShouldBe(expectedKey);
// And the host key trust, which is what stops this machine asking the user to check a fingerprint
// somebody has already checked. Read through the store the SSH handshake actually asks, so what is
// proved here is the answer a connection would get and not merely that a row arrived.
var knownHosts = new VaultKnownHostStore();
await knownHosts.OpenAsync(desktop, Token);
(await knownHosts.FindAsync(pin.Host, pin.Port, pin.Algorithm, Token))
.ShouldBe(pin.Fingerprint, "trust recorded on one machine has to reach the other");
// The algorithm is part of the identity, so a pin must not answer for a key the user never saw.
(await knownHosts.FindAsync(pin.Host, pin.Port, "ssh-rsa-that-was-never-offered", Token))
.ShouldBeNull();
}
///
/// A pin is the item type most likely to be given a plaintext column by mistake — it holds an address the
/// server may already know for a relay-enabled host, and a fingerprint that is public by nature. Together,
/// across a vault, they are the list of machines a user reaches. Asserted against the real endpoint's
/// answer, as the host and the key are.
///
private static async Task AssertTheServerLearnsNothingAboutTheTrustedHostAsync(
ServerConnection connection)
{
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
var page = await connection.Sync.SyncPullAsync(
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.KnownHostKey]), Token);
page.Changes.ShouldAllBe(change => change.EntityType == SyncEntityType.KnownHostKey);
var change = page.Changes.ShouldHaveSingleItem();
change.PlaintextFields.ShouldBeNull(
"which endpoints a user has approved is not something the server is told");
change.Payload.ShouldNotBeNull();
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
}
private static async Task AssertUnlocksOfflineAsync(ClientCacheFactory caches)
{
// Nothing here touches the network: the salt, the parameters and the wrapped bundle are local.
var offline = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
offline.IsUnlocked.ShouldBeTrue(offline.Message);
await offline.Session!.DisposeAsync();
}
///
///
/// Goes through the real trust-on-first-use path rather than around it. An unknown host key throws, the
/// caller pins it and retries — which is what the interface does, and the only way to prove the
/// fingerprint a user would be shown is the one the server actually presented.
///
///
/// Through the store that ships, so the pin is sealed under the vault key and queued for the server rather
/// than kept in a dictionary. That also means the answer the second handshake gets has been through a
/// real encrypt and decrypt, which is the property an in-memory store cannot exercise.
///
///
/// The host key that was approved, so a second machine can be asked whether it knows it.
private static async Task OpenAShellAsync(VaultSession laptop, HostSecret host)
{
var knownHosts = new VaultKnownHostStore();
await knownHosts.OpenAsync(laptop, Token);
var factory = new SshNetConnectionFactory(knownHosts);
var request = new SshConnectionRequest(
host.Hostname, host.Port, host.Username!, new SshPasswordCredential(DevStack.SshPassword));
HostKeyPresentation? pin = null;
try
{
await using var first = await factory.ConnectAsync(request, Token);
Assert.Fail("An unseen host key must not be trusted silently.");
}
catch (SshHostKeyUnknownException exception)
{
pin = exception.Presentation;
pin.Fingerprint.ShouldStartWith("SHA256:");
await knownHosts.TrustAsync(pin, Token);
}
await using var connection = await factory.ConnectAsync(request, Token);
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token);
var output = await ReadUntilEchoedAsync(shell, "dodossh-e2e-ok");
output.ShouldContain("dodossh-e2e-ok");
return pin.ShouldNotBeNull();
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
///
/// The provisioner takes the URL as a string because it is also the cache's identity — the value an
/// offline unlock compares against to refuse a cache belonging to another server.
///
private string ServerUrl => stack.ApiBaseUrl.ToString();
private HostSecret BuildHost(Guid sshKeyId) =>
new()
{
Label = "e2e-target",
Hostname = stack.SshHostname,
Port = stack.SshHostPort,
Username = DevStack.SshUsername,
Notes = "created by the end-to-end slice",
Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
SshKeyId = sshKeyId,
};
///
/// Armour of the right shape around material that is not a key. The shell at the end of this test
/// authenticates with a password, because what is under test here is the key's journey through the vault
/// — and a real private key committed to a repository is a real private key on the internet whatever it
/// was for. That SSH.NET can authenticate with a key delivered this way, as bytes rather than a file, is
/// established against a real sshd in KeyAuthenticationTests.
///
private static SshKeySecret BuildKey() =>
new()
{
Label = "e2e-deploy-key",
PrivateKeyPem =
"-----BEGIN OPENSSH PRIVATE KEY-----\nnot-a-real-key\n-----END OPENSSH PRIVATE KEY-----\n",
Passphrase = "an end to end key passphrase",
PublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 e2e@dodossh",
Notes = "created by the end-to-end slice",
};
private async Task OpenCacheAsync()
{
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-e2e-{Guid.CreateVersion7():N}");
Directory.CreateDirectory(directory);
directories.Add(directory);
var factory = ClientCacheFactory.ForFile(new ClientPaths(directory).CacheFile);
try
{
await factory.MigrateAsync(Token);
return factory;
}
catch
{
factory.Dispose();
throw;
}
}
private static async Task UnlockAsync(ClientCacheFactory caches)
{
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
return outcome.Session!;
}
///
/// Waits for the marker twice — once as the shell echoes the typed command, once as its output — rather
/// than for a fixed time. The login banner arrives first and its length is not something this test
/// should have to know.
///
private static async Task ReadUntilEchoedAsync(ISshShellSession shell, string marker)
{
var text = new StringBuilder();
var buffer = new byte[8192];
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Token);
deadline.CancelAfter(TimeSpan.FromSeconds(30));
while (!deadline.IsCancellationRequested)
{
var read = await shell.ReadAsync(buffer, deadline.Token);
if (read == 0)
{
break;
}
text.Append(Encoding.UTF8.GetString(buffer, 0, read));
if (Occurrences(text.ToString(), marker) >= 2)
{
break;
}
}
return text.ToString();
}
private static int Occurrences(string text, string marker)
{
var count = 0;
var index = 0;
while ((index = text.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += marker.Length;
}
return count;
}
}