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()
{
// 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 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);
// A second item type, in the same vault and the same outbox. Two of them 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 of those 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);
var pushed = await laptop.SyncAsync(connection.Sync, Token);
pushed.Pushed.ShouldBe(2);
pushed.NeedsAttention.ShouldBeFalse();
await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
await AssertTheServerLearnsNothingAboutTheKeyAsync(connection, keyId);
var seen = await ReadOnASecondMachineAsync(connection, host, entityId, key, keyId);
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);
}
///
/// 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);
}
private async Task ReadOnASecondMachineAsync(
ServerConnection connection,
HostSecret expected,
Guid entityId,
SshKeySecret expectedKey,
Guid keyId)
{
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(2, "the host and the key, in one pass");
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);
return seen.Secret;
}
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")]),
};
///
/// 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;
}
}