Public Access
A host now names the key it authenticates with, or none, as a field in its encrypted payload — so the choice follows the host to every machine rather than being made again each time somebody connects. The per-connection "Use key" switch it replaces was a stopgap for not having this, and keeping both would have left two mechanisms answering one question. This is the first payload schema version bump, and it does not work the obvious way. A host is written at the *lowest* schema version that can represent it: one that binds a key is written at 2, one that does not is still written at 1, byte for byte as it was before the field existed. The version is what makes an older client refuse to edit an item, so stamping 2 unconditionally would mean upgrading a single machine and renaming a single host made that host uneditable on every machine that had not upgraded yet. Confining the cost to the hosts that actually use the field is the difference between a team noticing a bump and a team being blocked by one. HostSecretCodec states the rule so the next field added follows it, and a test pins the version-1 bytes against a literal rather than against the codec, because the claim is about history: every host already in every vault has to re-encode to what it encoded before, or the first sync after an upgrade would push the whole vault as changed. A binding is an item id, not a copy of the key — a second copy of a private key is one that goes stale — which means the reference can dangle when the key is deleted on another machine. Both places that meets are handled the same way, by refusing rather than falling back: - Connecting to a host whose key is gone is refused outright. A host somebody deliberately set up for key-only access must not quietly start offering a password. - Opening such a host in the editor keeps the binding, selected, labelled as missing. The quieter version of the same failure is someone editing the port and saving, silently converting the host to password authentication with nothing ever having said so. Two things this found by being falsified: - The merge was untested for the new field, and "just take the server's value" passed the entire suite — a local binding change would have been discarded with no conflict recorded. HostSecretMergeTests already had a test written for exactly this class of omission; it simply had not been extended. - Adding a nullable field exposed a defect in HostSecretMerge.Field: it short-circuited when the discarded value was null, so the formatter never ran for the one case where null is a value rather than an absence, and a field whose absence has a name could not report it. Now the formatter always runs, and "no key" appears in the conflict log where an empty string used to. Also fixes eight nullable warnings in SyncEndpointTests left by the server-side SSH key commit, which had omitted the null-forgiving operator the rest of that file uses. They were invisible until an unrelated change forced the project to recompile. The end-to-end slice now binds its host to its key, so a schema-version-2 payload goes through the real API, the real PostgreSQL and back out on a second machine. 745 tests green. Zero warnings, dotnet format clean.
407 lines
17 KiB
C#
407 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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 <c>sshd</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStack>, IAsyncDisposable
|
|
{
|
|
private const string Passphrase = "an end to end passphrase";
|
|
|
|
/// <remarks>
|
|
/// 64 MiB is the floor <c>EnrollmentLimits</c> 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.
|
|
/// </remarks>
|
|
private static readonly Argon2Profile ServerFloorProfile =
|
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 64 * 1024, passes: 3, parallelism: 1);
|
|
|
|
private readonly List<string> directories = [];
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
|
|
// 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, 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 ----
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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 <c>SshKeyKind.Fields</c>.
|
|
/// </remarks>
|
|
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<HostSecret> 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();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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;
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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,
|
|
};
|
|
|
|
/// <remarks>
|
|
/// 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 <c>sshd</c> in <c>KeyAuthenticationTests</c>.
|
|
/// </remarks>
|
|
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<ClientCacheFactory> 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<VaultSession> UnlockAsync(ClientCacheFactory caches)
|
|
{
|
|
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
|
|
|
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
|
return outcome.Session!;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
private static async Task<string> 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;
|
|
}
|
|
}
|