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

@@ -205,7 +205,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent()
{
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, Token));
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, signIn: null, Token));
refusal.Message.ShouldContain("does not hold one");
spelunk.Requests.ShouldBeEmpty();
@@ -215,7 +215,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
public async Task AKeyPastedWithANewline_IsTrimmed()
{
await using var route = await new SpelunkTunnel()
.OpenAsync(spelunk.ApiBase, ServerId, ApiKey + "\r\n", 22, Token);
.OpenAsync(spelunk.ApiBase, ServerId, ApiKey + "\r\n", 22, signIn: null, Token);
spelunk.Requests[0].Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
}
@@ -230,11 +230,130 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
closed.Stop();
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, Token));
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, signIn: null, Token));
refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}");
}
// ---- Signing in through Spelunk ----
[Fact]
public async Task ASignIn_SendsThePublicKeyAndNoPort_AndLogsInAsTheAccountSpelunkNames()
{
spelunk.OnTicket = SignedInAs("root");
var signIn = SpelunkSignIn.Create();
await using var route = await spelunk.ApiBase.OpenAsync(port: 22, signIn);
// The one-line OpenSSH public key and nothing else: the port means nothing beside a key.
// Read as JSON rather than compared as text: the serialiser escapes base64's '+' as +, which is
// the same string to anything that parses it.
using (var body = System.Text.Json.JsonDocument.Parse(spelunk.Requests[0].Body))
{
body.RootElement.EnumerateObject().Select(property => property.Name).ShouldBe(["signIn"]);
var sent = body.RootElement.GetProperty("signIn");
sent.EnumerateObject().Select(property => property.Name).ShouldBe(["publicKey"]);
sent.GetProperty("publicKey").GetString().ShouldBe(signIn.PublicKeyLine);
}
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 AAAA");
// The same key comes back with the route, for the connection made through it, under Spelunk's account.
route.SignIn.ShouldBeSameAs(signIn);
route.User.ShouldBe("root");
// And the socket is spent exactly as a plain tunnel's is.
spelunk.Requests[1].Headers["Sec-WebSocket-Protocol"].ShouldBe($"spelunk.tunnel.v1, ticket.{FakeSpelunk.Ticket}");
}
[Fact]
public async Task ASignInAnswerNamingNoAccount_LogsInAsRoot()
{
await using var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create());
route.User.ShouldBe(SpelunkSignIn.DefaultUser);
}
[Fact]
public async Task APlainTunnel_HasNoSignIn_WhateverTheAnswerSays()
{
spelunk.OnTicket = SignedInAs("root");
await using var route = await OpenAsync();
route.SignIn.ShouldBeNull();
route.User.ShouldBeNull();
}
[Theory]
[InlineData("root; rm -rf /")]
[InlineData("root\\nsomeone")]
[InlineData("")]
public async Task ASignInAnswerNamingSomethingThatIsNotAnAccount_IsRefused(string user)
{
spelunk.OnTicket = SignedInAs(user);
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()));
refusal.Message.ShouldContain("does not understand");
spelunk.Requests.ShouldHaveSingleItem();
}
[Fact]
public async Task AKeySpelunkWillNotTake_IsExplainedInItsWords()
{
spelunk.OnTicket = _ => new FakeSpelunk.Answer(
HttpStatusCode.BadRequest,
"application/problem+json",
"""{"title":"One or more validation errors occurred.","status":400,"errors":{"publicKey":["The public key must be an ssh-ed25519 key."]}}""");
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()));
refusal.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
refusal.Message.ShouldContain("would not accept");
refusal.Message.ShouldContain("must be an ssh-ed25519 key");
spelunk.Requests.ShouldHaveSingleItem();
}
[Theory]
[InlineData("no OpenSSH server on this machine", "could not sign you in on this server (no OpenSSH server on this machine)")]
[InlineData("Unknown stream kind: signIn", "too old to sign you in")]
public async Task AnAgentThatCouldNotSignIn_SaysSo(string reason, string explanation)
{
spelunk.OnTunnel = socket =>
socket.CloseOutputAsync((WebSocketCloseStatus)4502, reason, CancellationToken.None);
var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create());
await EndOfStreamAsync(route);
route.Failure.ShouldNotBeNull().ShouldContain(explanation);
}
[Fact]
public void ASignInKey_IsNewEachTime_AndNeverPrinted()
{
var first = SpelunkSignIn.Create();
var second = SpelunkSignIn.Create();
first.PublicKeyLine.ShouldNotBe(second.PublicKeyLine);
first.PublicKeyLine.Split(' ').Length.ShouldBe(3);
first.Credential.Passphrase.ShouldBeNull();
Encoding.UTF8.GetString(first.Credential.PrivateKeyPem).ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----");
first.ToString().ShouldNotContain("AAAA");
first.ToString().ShouldNotContain("PRIVATE");
}
/// <summary>A ticket answer for a sign-in, naming the account it logs in as.</summary>
private static Func<FakeSpelunk.Request, FakeSpelunk.Answer> SignedInAs(string user) => request => FakeSpelunk.Answer.Json(
HttpStatusCode.OK,
$$"""
{"ticket":"{{FakeSpelunk.Ticket}}","expiresAt":"2026-09-28T12:00:30Z","path":"{{request.Path.Replace("tunnel-tickets", "tunnel", StringComparison.Ordinal)}}","subprotocol":"spelunk.tunnel.v1","user":"{{user}}"}
""");
private Task<SpelunkTunnelRoute> OpenAsync() => spelunk.ApiBase.OpenAsync(port: 22);
/// <summary>Speaks SOCKS5 to the bridge as SSH.NET would, and hands back the open stream.</summary>
@@ -273,11 +392,12 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
/// <summary>Opens a tunnel to the test server with the one key and server the suite uses.</summary>
file static class Opening
{
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port) =>
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port, SpelunkSignIn? signIn = null) =>
new SpelunkTunnel().OpenAsync(
apiBase,
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
port,
signIn,
TestContext.Current.CancellationToken);
}