Files
DodoSSH/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs
T
jaap-jan 95816de0c5 Share a vault with a team, without the server holding a key
M3's teams, sharing and ACLs. Teams with roles, a public-key directory, the
append-only key log served for clients to check it against, team-owned vaults,
and vault key grants wrapped by a client and stored opaquely by the server.
VaultAccessService resolves team membership to PermissionFlags, so a viewer may
pull and may not push; the desktop client reads and syncs every vault it holds
a key for, and a real TEAMS screen replaces the one that said it did not exist.
No migration: team, team_membership, vault.team_id and vault_key_grant have all
been there since the first one, which is what carrying two unused tables bought.

Membership is authorisation. A grant is access. The obvious model is one
concept — "access", with a role attached, handed out by the server — and this
architecture cannot implement it: a vault key is sealed to each member's X25519
key, and only a client holding the plaintext can seal it for somebody else. So
"give Bob access" decomposes into a database write and a wrap, which happen on
different machines. Adding a member makes the server serve them the vault; it
cannot make it readable. VaultSummary.WrappedVaultKey is null in the meantime
and the vault appears in their list saying it is waiting for a key, because
hiding it until a grant existed would have been tidier and would have implied
the server was the thing granting access. The screen says the same thing after
every add, in the status line. ADR 0009 records the whole decision.

Sharing verifies or refuses. A directory lookup is a claim by the server about
a third party's public key, and wrapping to an unverified claim hands the vault
to whoever made it — no amount of transport security helps, because the server
is inside the threat model. KeyLogAudit reads the whole log, recomputes every
entry's hash from its own contents, checks the chain from genesis, and refuses
unless the offered key appears in it unchanged. There is no override flag: one
that exists gets used on the day the log is briefly unreachable, and the
resulting grant is indistinguishable from a correct one afterwards. What it
still cannot promise is that the key is the right person's, so the fingerprint
comes back for an out-of-band comparison and the success message says so every
time. A test corrupts the fake server's log by one byte and watches the client
refuse rather than warn.

The roles are only the ones that are enforceable. There is no ConnectOnly,
despite the design asking for one and TeamRole having room: SSH terminates on
the client, so a session needs the credential's plaintext on that machine, and
"may connect but may not read the key" cannot be enforced here. Shipping it as
an option in a dropdown would have been a lie. Connect rides along with Read
and is documented as an interface hint. Removal is named for what it does — it
revokes grants and flags the vault for rekey, and claims nothing about what is
already on somebody's laptop.

Three things are deliberately absent, and each is a refusal rather than an
omission. The rekey itself, because re-wrapping every item's data key under a
new vault key needs a client holding the current one; the server records that a
rotation is owed and the interface reports it, which is more honest than a
button that only appears to do it. Ownership transfer, because allowing an
owner to be removed without one leaves a team nobody can administer. And
cross-vault host key trust: a pin in a team vault is listed but not consulted
at connect time, because any member with Write could otherwise pre-approve a
fingerprint another member's client then trusts silently for a host in their
own vault. Scoping trust properly needs a scope on the SSH connect path, which
IKnownHostStore has not got; until then the narrow direction is the safe one
and the cost is in the README rather than hidden.

Reading now spans vaults and writing still does not. Every list on the vault
and hosts screens covers each vault the keyring opened, rows carry the vault
they came from, and an edit goes back to that vault rather than to the active
one — writing it to the active vault would fork the item and only show up when
a colleague wondered why their change never arrived. A new item goes wherever a
picker says, defaulting to the personal vault and never moving on its own,
because an item filed into a team's vault is visible to that team and moving it
back means deleting and retyping. The sidebar heading stops naming one vault
once there are two, and each row names its own.

The server checks what it can and nothing it cannot. It will not record a grant
for a key its recipient no longer holds, for a superseded generation, or for
somebody who is not in the team — each of those would otherwise surface days
later at the far end as a tag failure indistinguishable from corruption. It
does not verify the wrap or the signature, and the grant service says so: that
would be a convenience and never the boundary, and would put an asymmetric
implementation on a machine that is supposed to hold no keys.

Two bugs the tests found. TeamsViewModel's busy gate blocked its own reload, so
a team created a moment earlier was missing from the list it had just been
added to. And syncing every vault turned a failure from an exception into a
report, which made a background pass announce an unreachable vault once a
minute — the exact behaviour AnAutomaticPassThatFails_LeavesTheStatusAlone
exists to prevent. The fact is recorded and the message swallowed, as it was
before; pressing Sync still names the vault and the reason.

Also fixes a build break this branch started with: QuickConnectTests was never
updated when M2 added ISftpSessionFactory to the shell's constructor, so
nothing built at all.
2026-07-31 12:18:28 +02:00

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, laptop.ActiveVaultId, 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, laptop.ActiveVaultId, 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, desktop.ActiveVaultId, 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;
}
}