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); using var connection = await SignInToTheStackAsync(browser); 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 ---- /// Signs in against the Keycloak this suite started for itself. /// /// /// The plaintext exemption is stated here rather than inherited from the shape of an address, and /// saying it out loud is the point. allows an http authority /// only when it is loopback — a sound rule, and not one this suite can lean on. Testcontainers /// reports the host a caller can actually reach it at, so running these tests directly yields /// localhost and passes, while running them inside a container yields the bridge gateway /// 172.17.0.1 and is refused. /// /// /// That refusal is the product being correct. 172.17.0.1 is genuinely not loopback, and a client /// that quietly accepted plaintext metadata from a routable address would be a real weakness for /// everybody who is not a test. So the exemption is claimed here, by the one caller that knows it /// started the provider itself and that it lives for the length of one test, and the rule stays /// exactly as strict for everyone else. /// /// private Task SignInToTheStackAsync(ScriptedBrowser browser) => ServerConnection.SignInAsync( stack.ApiBaseUrl, browser, TimeProvider.System, Token, configureOidc: options => options with { RequireHttpsMetadata = false }); /// /// 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; } }