Public Access
A fingerprint approved once is now approved on every machine and survives a
restart, because host key trust is a vault item type rather than a dictionary
that dies with the process. InMemoryKnownHostStore was what shipped, so the user
was asked to verify a fingerprint on every single connection — which is the gap
most likely to train somebody to click through the one warning that actually
matters. A warning that appears when nothing is wrong teaches that nothing is
ever wrong.
The fourth item type, and like the third it cost no sync logic: a row, an EF
configuration, a migration, a server kind; a secret, a codec, a merge, a cipher,
a repository facade and a session property. One row in the client registry. The
reconciler, the mirror, the repository, the outbox and the pull filter were not
touched. SyncEntityType.KnownHostKey and AadResourceType.KnownHostKey were
already reserved, so neither the contract nor docs/crypto.md changed.
One item per (host, port, algorithm), because a server legitimately offers
several host keys and which one gets negotiated is not ours to predict. Pinning
per endpoint would make an algorithm change indistinguishable from an attack.
The label is derived rather than stored, which is the one place this type
departs from the other three. A user never names a pin — there is nothing to
name it after but the three fields it already has — and a stored label is a
second copy of data that can disagree with the first after a merge. Relabel
returns the secret unchanged, and says why.
The store answers the handshake without touching the disk. SshNetConnectionFactory
calls FindAsync from inside SSH.NET's synchronous HostKeyReceived event, over
.GetAwaiter().GetResult(), which cannot be avoided; doing SQLite I/O plus an AEAD
open per lookup there would put the handshake behind the cache. So decryption
happens in OpenAsync and RefreshAsync — on unlock and after each sync pass,
exactly where the host and key lists already reload — and FindAsync is a
dictionary read under a lock with no await inside it.
That snapshot is where the one real bug in this change lived. Install originally
merged the live pins over the freshly loaded snapshot, to protect a TrustAsync
that had landed while the read was in flight. It would also have resurrected
every pin the user had just forgotten, and stopped a withdrawal made on another
machine from ever taking effect — the store would have healed the deletion back
into existence on every refresh. Replacing wholesale and discarding the read
instead is correct because writes are the rare case: every write bumps a
generation counter, and a refresh whose stamp is stale throws itself away rather
than winning. Nothing found this but reading the method again; it is the kind of
mistake that passes every test written before it, because the test that catches
it is the one the bug tells you to write.
Forgetting is new, and persistence is what made it mandatory rather than
convenient. A mismatch is a hard refusal with no way to continue — deliberately,
and that stays — so pinning a key permanently is also a way to make a
legitimately rebuilt server permanently unreachable. Before this change the pin
died at exit and the problem solved itself; now it does not.
ForgetAsync drops every algorithm for an endpoint, and it is reachable from the
host editor rather than from the warning. Putting it on the mismatch banner would
have made it two clicks from "this may be an attack" to "connect anyway", which
is the affordance the hard refusal exists to deny. The banner already promised
the key could be removed in the host's settings; that promise is now true and
points at the button.
Trust recorded on another machine becomes visible at the next sync pass, not
immediately, and that is a decision rather than an oversight. The failure it
produces is a first-contact prompt for a host a colleague approved a minute ago:
answerable, and self-correcting on the next pass. The opposite trade — polling
the vault on the handshake thread to close a one-minute window — buys nothing
and costs the property above. The dangerous direction is not reachable at all: a
pin recorded here enters the snapshot as part of recording it, so a refresh can
never discard a local trust decision.
The server learns nothing, and this is the item type where the temptation was
real. A plaintext host column would let a known-hosts screen sort and page
without decrypting anything, and it would hand the operator the map of every
user's estate — assembled, as these things are, out of facts that are each
individually harmless. A host row concedes an address only when relay is
switched on and the database refuses to store one otherwise (ADR 0004); there is
no equivalent excuse here. The table has no column to put one in, and the EF
configuration says so where somebody adding it would be standing.
Two things about the migration in this commit are worth knowing, because both
came out of getting it wrong.
It was hand-written first, including its .Designer.cs, and that version is not
what is here. Verifying it turned up something that had been quietly assumed:
Migration_AppliedCleanly_WithNoPendingModelChanges does not check the model
snapshot. It asserts that migrations applied and that none are pending, which a
wrong snapshot satisfies perfectly — the snapshot only matters as the diff base
for the *next* migrations add, so an incorrect one passes the whole suite and
corrupts the following migration instead. The real check is to generate a
throwaway migration and confirm its Up and Down come out empty. They did, and
the generated designer was byte-identical to the transcribed one across all 1255
lines, so the hand-written work was in fact correct.
Then dotnet ef migrations remove --no-build deleted the wrong migration. With
--no-build the tool reads the previously compiled assembly rather than the files
on disk, and the probe had just changed which migration was last, so it removed
AddKnownHostKeyItem and reverted the snapshot. That turned out to leave exactly
the right diff base, so the migration here is EF's own output rather than a
transcription — a better outcome than the one that was interrupted, arrived at
by accident. Never pass --no-build to migrations remove.
Mutation tested, all three sabotages detected: dropping the algorithm from
KnownHostIdentity.For, merging instead of replacing in Install, and pointing
KnownHostKeyCipher at PortForward — which is what a cast from the wire enum's 10
would silently produce. Each is caught both by an assertion about the mechanism
and by a behavioural test that never mentions it; the resource-type sabotage is
caught by the table from d10a38d and nothing else, which is what that table is
for.
The end-to-end slice now approves the real sshd's host key through the vault,
pushes it, and reads it back on the second simulated machine — including a check
that the server learned no address, and that the second machine answers null for
an algorithm never offered.
845 tests green. Zero warnings, dotnet format clean.
Three things are deliberately not fixed. A tombstone queued over a create that
was never pushed is refused by the server as Invalid and parked; that is
pre-existing for all four item types, and the fix belongs in
VaultItemRepository.DeleteAsync rather than here. Deleting a host, or changing
its address, orphans its pins — both are correct as trust decisions, since a pin
describes an endpoint and not a bookmark, but nothing surfaces the leftovers.
And there is no interface listing pins at all: trust is created at the connect
prompt and withdrawn in the host editor. A known-hosts list is where the orphans
would become visible, and it wants the vault column rework first, for the same
reason the credential editor does.
475 lines
20 KiB
C#
475 lines
20 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);
|
|
|
|
// 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, Token);
|
|
trusted.Pushed.ShouldBe(1, "the host key the user approved at the prompt");
|
|
trusted.NeedsAttention.ShouldBeFalse();
|
|
|
|
await AssertTheServerLearnsNothingAboutTheTrustedHostAsync(connection);
|
|
|
|
await ReadOnASecondMachineAsync(connection, host, entityId, key, keyId, pin);
|
|
|
|
await AssertUnlocksOfflineAsync(laptopCache);
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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 <c>sshd</c> — already knows the fingerprint
|
|
/// the other one approved.
|
|
/// </remarks>
|
|
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, Token);
|
|
pulled.Pulled.ShouldBe(3, "the host, the key and the approved host 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);
|
|
|
|
// 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();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <returns>The host key that was approved, so a second machine can be asked whether it knows it.</returns>
|
|
private static async Task<HostKeyPresentation> 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;
|
|
|
|
/// <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;
|
|
}
|
|
}
|