Sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link

A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.

A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
This commit is contained in:
jaap-jan committed 2026-09-28 22:53:51 +02:00
1 parent 07bd5c9886
commit 38ab9b431b
18 files changed
+1239 -115

No files matched your search

@@ -20,8 +20,9 @@ namespace DodoSSH.Client.App.Tests;
/// </para>
/// <para>
/// What these hold is what the tick box and the link promise: a Spelunk host is never dialled directly and
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, and a link
/// never connects anything the user has not saved.
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, a sign-in
/// connects with the tunnel's key and Spelunk's account and nothing of the host's, and a link connects
/// straight away but never sends a key to a Spelunk no saved host already sends it to.
/// </para>
/// </remarks>
public sealed partial class ShellFlowTests
@@ -173,6 +174,131 @@ public sealed partial class ShellFlowTests
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("web-01");
}
// ---- Signing in through Spelunk ----
[Fact]
public async Task ASignInHost_ConnectsWithAFreshKeyAndSpelunksAccount_AndNoLoginOfItsOwn()
{
spelunkTunnels.User = "ops";
var vault = await ReadyToSignInThroughSpelunkAsync();
vault.SelectedHostAsksForAPassword.ShouldBeFalse("there is nothing to type");
vault.SelectedHostAuthenticationNote.ShouldContain("Spelunk signs you in");
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 ");
// The account Spelunk named and the key the tunnel was opened with, through the tunnel, as the host.
var request = ssh.Requests.ShouldHaveSingleItem();
request.Username.ShouldBe("ops");
request.Credential.ShouldBeSameAs(signIn.Credential);
request.Host.ShouldBe("203.0.113.10");
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
shell.IsTerminalShowing.ShouldBeTrue();
shell.Tabs.ShouldHaveSingleItem();
vault.Credentials.Count.ShouldBe(1, "nothing is remembered for a sign-in");
await request.Proxy.Lifetime.DisposeAsync();
}
[Fact]
public async Task ASignInHostsForwards_StillStart_OverTheSignedInSession()
{
var vault = await ReadyToSignInThroughSpelunkAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewForwardKind = (int)Domain.PortForwardKind.Dynamic;
vault.EditorNewForwardBindPort = "1080";
vault.EditorNewForwardTarget = string.Empty;
vault.AddEditorForwardCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
await EventuallyAsync(() => tab.ListeningForwardCount == 1, "the forward should be listening");
ssh.Forwards.ShouldHaveSingleItem().Spec.ShouldBe(new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080));
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ASignInHostsFileTransfer_SignsInThroughSpelunkToo()
{
var vault = await ReadyToSignInThroughSpelunkAsync();
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
shell.Transfers.SelectedHostAsksForAPassword.ShouldBeFalse();
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
var request = ssh.SftpRequests.ShouldHaveSingleItem();
request.Username.ShouldBe("root");
request.Credential.ShouldBeSameAs(signIn.Credential);
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
shell.Transfers.ConnectedTo.ShouldStartWith("root@203.0.113.10");
await request.Proxy.Lifetime.DisposeAsync();
}
[Fact]
public async Task ASpelunkHostThatDoesNotSignIn_StillNeedsALogin()
{
// The sign-in is what lets a host connect with no binding; without it, the old rule stands.
var vault = await ReadyToSignInThroughSpelunkAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSpelunkSignIn = false;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.Single();
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status.ShouldNotBeNull().ShouldContain("no username");
spelunkTunnels.Opened.ShouldBeEmpty();
ssh.Requests.ShouldBeEmpty();
}
[Fact]
public async Task TickingSignIn_GreysOutTheLogin_AndTheSavedHostBindsNothing()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorAsksForLogin.ShouldBeTrue();
vault.EditorSpelunkSignIn = true;
vault.EditorAsksForLogin.ShouldBeFalse();
// The choice is still there to see, and to come back to, but it is not saved beside the sign-in.
vault.EditorSelectedAuthentication.ShouldNotBeNull().Label.ShouldBe("deploy password");
await vault.SaveHostCommand.ExecuteAsync(null);
var host = vault.Hosts.ShouldHaveSingleItem().Host;
host.SignsInThroughSpelunk.ShouldBeTrue();
host.CredentialId.ShouldBeNull();
host.SshKeyId.ShouldBeNull();
host.AsksForPassword.ShouldBeNull();
// Unticking the route altogether gives the login back.
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSpelunkSignIn.ShouldBeTrue();
vault.EditorThroughSpelunk = false;
vault.EditorAsksForLogin.ShouldBeTrue();
}
// ---- A link from Spelunk ----
[Fact]
@@ -188,12 +314,15 @@ public sealed partial class ShellFlowTests
vault.EditorHostname.ShouldBe("203.0.113.10");
vault.EditorPort.ShouldBe(22);
vault.EditorThroughSpelunk.ShouldBeTrue();
vault.EditorSpelunkSignIn.ShouldBeTrue("a host from a link signs in through Spelunk");
vault.EditorAsksForLogin.ShouldBeFalse();
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
// No key chosen for a Spelunk no host already uses: the link named the address, and guessing a key
// for it would be offering to send the key wherever a link says.
vault.EditorSelectedSpelunkKey.ShouldBeNull();
vault.Status.ShouldNotBeNull().ShouldContain("first server");
spelunkTunnels.Opened.ShouldBeEmpty();
ssh.Requests.ShouldBeEmpty();
@@ -211,34 +340,138 @@ public sealed partial class ShellFlowTests
}
[Fact]
public async Task ALinkForASecondServerOnAKnownSpelunk_PicksTheKeyThatSpelunkAlreadyGets()
public async Task TheFirstLinkFromASpelunk_TagsTheHostItOpens_InThePersonalVault()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
await UnlockedAsync();
var vault = shell.Vault!;
shell.OpenLink(Link(server: Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c")));
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
vault.IsEditing.ShouldBeTrue();
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
vault.EditorSelectedVault.ShouldNotBeNull().IsPersonal.ShouldBeTrue();
vault.EditorTagChoices.ShouldHaveSingleItem().ShouldSatisfyAllConditions(
chip => chip.Label.ShouldBe(VaultViewModel.SpelunkTag),
chip => chip.IsWorn.ShouldBeTrue());
// So once it is saved, the next link for that server finds it rather than adding a second one.
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
await vault.SaveHostCommand.ExecuteAsync(null);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
vault.Hosts.ShouldHaveSingleItem();
spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ALinkForASavedHost_ConnectsToIt()
public async Task ALinkForAnotherServerOnAKnownSpelunk_IsAddedAndConnected_WithoutTheEditor()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(host: null, server: other));
vault.IsEditing.ShouldBeFalse("nothing to choose: the key already goes to this Spelunk");
// Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk with the
// key that Spelunk already gets, and no login of its own.
var added = vault.Hosts.Single(row => row.Host.Spelunk?.ServerId == other);
added.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId);
added.Label.ShouldBe("web 01");
added.Host.Hostname.ShouldBe($"spelunk-{other}");
added.Host.Port.ShouldBe(22);
added.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi);
added.Host.Spelunk.SignInThroughSpelunk.ShouldBeTrue();
added.Host.Spelunk.ApiKeyCredentialId.ShouldBe(vault.Credentials.Single(row => row.Label == "spelunk key").EntityId);
added.Host.SshKeyId.ShouldBeNull();
added.Host.CredentialId.ShouldBeNull();
added.Host.Username.ShouldBeNull();
added.Host.TagIds.ShouldHaveSingleItem().ShouldBe(vault.Tags.Single(tag => tag.Label == VaultViewModel.SpelunkTag).EntityId);
// And connected, through Spelunk, signing in.
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
opened.ShouldBe((SpelunkApi, other, SpelunkApiKey, 22));
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
var request = ssh.Requests.ShouldHaveSingleItem();
request.Username.ShouldBe("root");
request.Credential.ShouldBeSameAs(signIn.Credential);
shell.IsTerminalShowing.ShouldBeTrue();
await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ASecondLinkForTheSameServer_ConnectsToTheHostTheFirstAdded()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(server: other));
await vault.OpenSpelunkLinkAsync(Link(server: other));
vault.Hosts.Count(row => row.Host.Spelunk?.ServerId == other).ShouldBe(1);
spelunkTunnels.Opened.Count.ShouldBe(2);
// The two SignIns are two keys: one per connection, never reused.
spelunkTunnels.SignIns[0].ShouldNotBeNull().PublicKeyLine
.ShouldNotBe(spelunkTunnels.SignIns[1].ShouldNotBeNull().PublicKeyLine);
foreach (var request in ssh.Requests)
{
await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
}
[Fact]
public async Task ALinkForATaggedHostInThePersonalVault_ConnectsToItAsSaved()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewTag = VaultViewModel.SpelunkTag;
await vault.AddEditorTagCommand.ExecuteAsync(null);
await vault.SaveHostCommand.ExecuteAsync(null);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7"));
vault.IsEditing.ShouldBeFalse();
vault.Hosts.ShouldHaveSingleItem();
spelunkTunnels.Opened.ShouldHaveSingleItem();
// The saved host, as saved — not the address the link happened to carry.
// The saved host, as saved — not the address the link happened to carry, and its own login, since
// this one does not sign in through Spelunk.
spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldBeNull();
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("203.0.113.10");
request.Username.ShouldBe("deploy");
await request.Proxy.ShouldNotBeNull().Lifetime.DisposeAsync();
}
[Fact]
public async Task ALinkForAHostSetUpByHand_AddsOneOfItsOwn()
{
// Untagged, so not one a link made: it is left as it is, and the link gets the host links make.
var vault = await ReadyToConnectThroughSpelunkAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
vault.Hosts.Count.ShouldBe(2);
vault.Hosts.Single(row => row.Label == "web-01").Host.TagIds.ShouldBeEmpty();
vault.Hosts.Single(row => row.Label == "web 01").Host.SignsInThroughSpelunk.ShouldBeTrue();
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ALinkWhileLocked_WaitsForTheUnlock()
{
@@ -255,6 +488,7 @@ public sealed partial class ShellFlowTests
var vault = shell.Vault.ShouldNotBeNull();
vault.IsEditing.ShouldBeTrue();
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
vault.EditorSpelunkSignIn.ShouldBeTrue();
}
[Fact]
@@ -304,6 +538,32 @@ public sealed partial class ShellFlowTests
return vault;
}
/// <summary>A saved host through Spelunk that Spelunk signs in to: no username, no key, no password.</summary>
private async Task<VaultViewModel> ReadyToSignInThroughSpelunkAsync()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "web-01";
vault.EditorHostname = "203.0.113.10";
vault.EditorThroughSpelunk = true;
vault.EditorSpelunkSignIn = true;
vault.EditorSpelunkApi = SpelunkApi;
vault.EditorSpelunkServerId = SpelunkServer.ToString();
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status);
vault.SelectedHost.Host.SignsInThroughSpelunk.ShouldBeTrue();
return vault;
}
private static SpelunkLink Link(string? host = "203.0.113.10", Guid? server = null) =>
new(SpelunkApi, server ?? SpelunkServer, "web 01", host);
@@ -338,6 +598,12 @@ public sealed partial class ShellFlowTests
{
internal List<(string ApiBase, Guid ServerId, string ApiKey, int Port)> Opened { get; } = [];
/// <summary>The sign-in key each tunnel was asked for, in order; null for a plain tunnel.</summary>
internal List<SpelunkSignIn?> SignIns { get; } = [];
/// <summary>The account a sign-in answer names.</summary>
internal string User { get; set; } = "root";
internal SpelunkTunnelException? Refusal { get; set; }
/// <summary>A close Spelunk sends as soon as the tunnel is open, or null to leave it open.</summary>
@@ -348,9 +614,11 @@ public sealed partial class ShellFlowTests
Guid serverId,
string apiKey,
int port,
SpelunkSignIn? signIn,
CancellationToken cancellationToken)
{
Opened.Add((apiBase, serverId, apiKey, port));
SignIns.Add(signIn);
if (Refusal is { } refusal)
{
@@ -364,7 +632,7 @@ public sealed partial class ShellFlowTests
await far.CloseOutputAsync((WebSocketCloseStatus)close.Code, close.Reason, cancellationToken);
}
return new SpelunkTunnelRoute(RelayBridge.Start(client), port);
return new SpelunkTunnelRoute(RelayBridge.Start(client), port, signIn, signIn is null ? null : User);
}
private static async Task<(WebSocket Client, WebSocket Far)> PairAsync(CancellationToken cancellationToken)