using System.Text; using DodoSSH.Client.Domain; using DodoSSH.Client.Session; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; 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() { var account = await stack.CreateUserAsync(Token); var browser = new ScriptedBrowser(account.Username, account.Password); using var connection = await ServerConnection .SignInAsync(stack.ApiBaseUrl, browser, TimeProvider.System, Token); AssertDiscoveredFromTheServer(connection); using var laptopCache = await OpenCacheAsync(); await EnrollAsync(connection, laptopCache, browser); var laptop = await UnlockAsync(laptopCache); await using var laptopSession = laptop; var host = BuildHost(); var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token); var pushed = await laptop.SyncAsync(connection.Sync, Token); pushed.Pushed.ShouldBe(1); pushed.NeedsAttention.ShouldBeFalse(); await AssertTheServerCannotSeeTheAddressAsync(connection, entityId); var seen = await ReadOnASecondMachineAsync(connection, host, entityId); await AssertUnlocksOfflineAsync(laptopCache); await OpenAShellAsync(seen); } // ---- 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. /// 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); } private async Task ReadOnASecondMachineAsync( ServerConnection connection, HostSecret expected, Guid entityId) { 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, Token); pulled.Pulled.ShouldBe(1); var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token); var seen = listing.Hosts.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.Host.ShouldBe(expected); return seen.Host; } 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. /// private static async Task OpenAShellAsync(HostSecret host) { var knownHosts = new InMemoryKnownHostStore(); var factory = new SshNetConnectionFactory(knownHosts); var request = new SshConnectionRequest( host.Hostname, host.Port, host.Username!, new SshPasswordCredential(DevStack.SshPassword)); try { await using var first = await factory.ConnectAsync(request, Token); Assert.Fail("An unseen host key must not be trusted silently."); } catch (SshHostKeyUnknownException exception) { exception.Presentation.Fingerprint.ShouldStartWith("SHA256:"); await knownHosts.TrustAsync(exception.Presentation, 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"); } // ---- 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() => 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")]), }; 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; } }