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

+4 -2
View File
@@ -18,8 +18,10 @@ namespace DodoSSH.Client.Session;
/// <para>
/// <b>Anything on the machine can hand this application a link</b> — a web page, a mail, another program —
/// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape,
/// and the most it can ever do is select a host the user already saved, or open the editor on a new one
/// that the user then has to finish and save. It never connects to anything the user has not saved.
/// and the most it can ever do is connect to a host a link made before, add one for another server on a
/// Spelunk a saved host already sends its API key to — with that same key — or open the editor on a new
/// one that the user then has to finish and save. It never sends an API key anywhere a saved host does
/// not already send it. See <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
/// </para>
/// </remarks>
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
@@ -0,0 +1,81 @@
using System.Text;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Session;
/// <summary>
/// The throwaway key one connection signs in to a Spelunk server with.
/// </summary>
/// <remarks>
/// <para>
/// For a host set to sign in through Spelunk — see <c>SpelunkRoute.SignInThroughSpelunk</c> — each connection
/// makes one of these, sends <see cref="PublicKeyLine"/> with its ticket request, and authenticates with
/// <see cref="Credential"/> once the tunnel is up. Spelunk's agent runs a one-connection SSH server whose only
/// authorised key is this one, so the key is worth nothing after the connection it was made for.
/// </para>
/// <para>
/// <b>Made in memory and never kept.</b> Nothing writes it to a disk or to the vault, and nothing logs it:
/// <see cref="ToString"/> says what it is and nothing of what it holds. Ed25519, which is what
/// <see cref="SshKeyGenerator"/> makes instantly and what every OpenSSH the agent could start accepts.
/// </para>
/// </remarks>
public sealed class SpelunkSignIn
{
/// <summary>The account Spelunk signs in as when its answer does not name one.</summary>
public const string DefaultUser = "root";
/// <summary>The comment the public key carries, which is what Spelunk and the server's logs see.</summary>
internal const string KeyComment = "dodossh-spelunk-sign-in";
private SpelunkSignIn(string publicKeyLine, SshPrivateKeyCredential credential)
{
PublicKeyLine = publicKeyLine;
Credential = credential;
}
/// <summary>The public half, as one OpenSSH <c>authorized_keys</c> line: <c>ssh-ed25519 AAAA… comment</c>.</summary>
public string PublicKeyLine { get; }
/// <summary>The private half, in the form a connection authenticates with.</summary>
public SshPrivateKeyCredential Credential { get; }
/// <summary>Makes a fresh key pair for one connection.</summary>
public static SpelunkSignIn Create()
{
var key = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, KeyComment);
return new SpelunkSignIn(
key.PublicKeyLine.Trim(),
new SshPrivateKeyCredential(Encoding.UTF8.GetBytes(key.PrivateKeyArmour), Passphrase: null));
}
/// <inheritdoc />
/// <remarks>Says nothing of the key, so one that reaches a log by accident takes nothing with it.</remarks>
public override string ToString() => "Spelunk sign-in key";
}
/// <summary>
/// The credential of a connection that will sign in through Spelunk, before the tunnel has made its key.
/// </summary>
/// <remarks>
/// <para>
/// A host that signs in through Spelunk is resolved to a login before anything is opened — the same
/// resolution every host goes through — but its key does not exist until the tunnel is opened, and the
/// account is whatever Spelunk then says. This stands in for the key until the route replaces it, together
/// with the username, as the connection is made through it.
/// </para>
/// <para>
/// <b>It authenticates nothing.</b> The SSH layer knows only passwords and private keys, and refuses any other
/// credential before it dials — so a path that forgot to take the route's key fails closed rather than
/// sending something else in its place.
/// </para>
/// </remarks>
public sealed record SpelunkSignInPending : SshCredential
{
/// <summary>The one value; there is nothing in it to tell two apart.</summary>
public static SpelunkSignInPending Instance { get; } = new();
private SpelunkSignInPending()
{
}
}
+119 -13
View File
@@ -48,7 +48,10 @@ public static class SpelunkTunnelProtocol
/// <summary>Close code for an agent that disconnected from Spelunk while the tunnel was open.</summary>
public const int AgentDisconnectedCloseCode = 4503;
/// <summary>What an agent that predates tunnels says, inside a <see cref="TargetUnreachableCloseCode"/>.</summary>
/// <summary>
/// What an agent that predates tunnels — or, asked to sign in, predates that — says, inside a
/// <see cref="TargetUnreachableCloseCode"/>.
/// </summary>
internal const string UnknownStreamKind = "Unknown stream kind";
/// <summary>Where a ticket for one server is asked for.</summary>
@@ -67,7 +70,15 @@ public interface ISpelunkTunnel
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
/// <param name="serverId">The server's id in Spelunk.</param>
/// <param name="apiKey">The Spelunk API key, which authorises the ticket and is sent nowhere else.</param>
/// <param name="port">The port the agent is to connect to on the server's own loopback.</param>
/// <param name="port">
/// The port the agent is to connect to on the server's own loopback. Not sent when signing in, where the
/// agent starts an SSH server of its own and Spelunk ignores it.
/// </param>
/// <param name="signIn">
/// The throwaway key to have Spelunk sign in with, or null for a plain tunnel to <paramref name="port"/>.
/// Its public half goes with the ticket request; the route hands the whole of it back, with the account
/// Spelunk named, for the connection made through it.
/// </param>
/// <param name="cancellationToken">Cancellation.</param>
/// <remarks>
/// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
@@ -81,6 +92,7 @@ public interface ISpelunkTunnel
Guid serverId,
string apiKey,
int port,
SpelunkSignIn? signIn,
CancellationToken cancellationToken);
}
@@ -99,32 +111,59 @@ public sealed class SpelunkTunnelRoute : IConnectionRoute
private readonly RelayBridge bridge;
private readonly int port;
internal SpelunkTunnelRoute(RelayBridge bridge, int port)
internal SpelunkTunnelRoute(RelayBridge bridge, int port, SpelunkSignIn? signIn = null, string? user = null)
{
this.bridge = bridge;
this.port = port;
SignIn = signIn;
User = signIn is null ? null : user ?? SpelunkSignIn.DefaultUser;
}
/// <inheritdoc />
public SshLoopbackProxy Proxy => bridge.Proxy;
/// <summary>The key Spelunk was asked to sign in with, or null for a plain tunnel.</summary>
/// <remarks>
/// Handed back rather than kept by whoever asked, so the connection made through this route takes its
/// credential from the same place it takes its proxy, and cannot be given one without the other.
/// </remarks>
public SpelunkSignIn? SignIn { get; }
/// <summary>
/// The account Spelunk signs in as — its answer's <c>user</c>, or <see cref="SpelunkSignIn.DefaultUser"/>
/// when it named none — or null for a plain tunnel.
/// </summary>
public string? User { get; }
/// <inheritdoc />
/// <remarks>
/// A normal close is not a failure worth rewording: it is the SSH server hanging up, and SSH.NET's own
/// account of that is the accurate one.
/// </remarks>
public string? Failure =>
bridge.CloseStatus is { } status ? Describe((int)status, bridge.CloseDescription, port) : null;
bridge.CloseStatus is { } status
? Describe((int)status, bridge.CloseDescription, port, signingIn: SignIn is not null)
: null;
/// <inheritdoc />
public ValueTask DisposeAsync() => bridge.DisposeAsync();
/// <summary>What a close code from Spelunk means, for someone who has to do something about it.</summary>
internal static string? Describe(int code, string? reason, int port) => code switch
internal static string? Describe(int code, string? reason, int port, bool signingIn = false) => code switch
{
SpelunkTunnelProtocol.TargetUnreachableCloseCode
when reason?.Contains(SpelunkTunnelProtocol.UnknownStreamKind, StringComparison.OrdinalIgnoreCase) is true =>
"The Spelunk agent on this server is too old to open a terminal. Update the agent, then try again.",
signingIn
? "The Spelunk agent on this server is too old to sign you in. Update the agent, then try again."
: "The Spelunk agent on this server is too old to open a terminal. Update the agent, then try again.",
// Signing in, the agent connects to nothing: it starts an SSH server of its own, and what it could not
// do is that — most often because the server has no OpenSSH server to start, which Spelunk's reason
// says in its own words.
SpelunkTunnelProtocol.TargetUnreachableCloseCode when signingIn =>
"The Spelunk agent could not sign you in on this server"
+ (Readable(reason) is { } said ? $" ({said})" : string.Empty)
+ ". Signing in through Spelunk needs OpenSSH's server (sshd) installed there.",
SpelunkTunnelProtocol.TargetUnreachableCloseCode =>
string.Create(
@@ -223,6 +262,9 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
/// <summary>The longest ticket accepted. Far beyond Spelunk's; a bound on what goes into a header.</summary>
private const int MaxTicketLength = 2048;
/// <summary>The longest account name taken from Spelunk's answer — well past what a Unix system allows.</summary>
private const int MaxUserLength = 64;
/// <summary>The longest error body read, so a misbehaving proxy cannot hand this a gigabyte.</summary>
private const int MaxProblemBytes = 16 * 1024;
@@ -263,6 +305,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
Guid serverId,
string apiKey,
int port,
SpelunkSignIn? signIn,
CancellationToken cancellationToken)
{
if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason))
@@ -270,7 +313,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
throw new SpelunkTunnelException(reason);
}
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, cancellationToken)
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, signIn, cancellationToken)
.ConfigureAwait(false);
var socket = await connect(
@@ -279,7 +322,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
cancellationToken)
.ConfigureAwait(false);
return new SpelunkTunnelRoute(RelayBridge.Start(socket), port);
return new SpelunkTunnelRoute(RelayBridge.Start(socket), port, signIn, ticket.User);
}
/// <summary>
@@ -320,6 +363,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
Guid serverId,
string apiKey,
int port,
SpelunkSignIn? signIn,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(
@@ -330,8 +374,16 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
// Serialised up front rather than streamed, so the request carries a Content-Length: a dozen bytes
// do not need chunked encoding, and not every proxy in front of an API accepts it.
//
// Signing in sends the public key and no port: the agent starts an SSH server of its own rather than
// connecting to one, and Spelunk ignores the port beside a key anyway.
request.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(
new SpelunkTicketRequestDocument { Port = port },
signIn is null
? new SpelunkTicketRequestDocument { Port = port }
: new SpelunkTicketRequestDocument
{
SignIn = new SpelunkSignInRequestDocument { PublicKey = signIn.PublicKeyLine },
},
SpelunkTunnelJsonContext.Default.SpelunkTicketRequestDocument))
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") },
@@ -365,13 +417,15 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
throw new SpelunkTunnelException(Describe(response.StatusCode, problem), response.StatusCode);
}
return await ReadTicketAsync(response, cancellationToken).ConfigureAwait(false);
return await ReadTicketAsync(response, signingIn: signIn is not null, cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>Reads the ticket, and refuses one this client would have to take on trust.</summary>
private static async Task<SpelunkTicketDocument> ReadTicketAsync(
HttpResponseMessage response,
bool signingIn,
CancellationToken cancellationToken)
{
SpelunkTicketDocument? ticket;
@@ -397,7 +451,8 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|| path.StartsWith("//", StringComparison.Ordinal)
|| path.Any(character => character is '?' or '#' or '\\' or '@' || char.IsWhiteSpace(character) || char.IsControl(character))
|| (ticket.Subprotocol is { } named
&& !string.Equals(named, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal)))
&& !string.Equals(named, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal))
|| (signingIn && ticket.User is { } user && !IsAccountName(user)))
{
throw new SpelunkTunnelException(Unintelligible);
}
@@ -412,6 +467,18 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
private static bool IsBase64UrlCharacter(char character) =>
char.IsAsciiLetterOrDigit(character) || character is '-' or '_';
/// <summary>
/// Whether the account Spelunk named is one an SSH login could be for: a short run of the characters
/// Unix account names are made of.
/// </summary>
/// <remarks>
/// Checked because it goes to the server as the login and onto the tab and the status line, and an
/// answer carrying anything else is not one this client understands.
/// </remarks>
private static bool IsAccountName(string user) =>
user.Length is > 0 and <= MaxUserLength
&& user.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' or '.');
/// <summary>What a refused ticket means, in the words of the status and of Spelunk's own detail.</summary>
private static string Describe(HttpStatusCode status, SpelunkProblemDocument? problem)
{
@@ -419,6 +486,15 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
return status switch
{
// Spelunk's validation says which field and why; for a sign-in it is the public key.
HttpStatusCode.BadRequest =>
"Spelunk would not accept the request to open the tunnel"
+ ((SpelunkTunnelRoute.Readable(PublicKeyError(problem))
?? detail
?? SpelunkTunnelRoute.Readable(problem?.Title)) is { } said
? $": {said}"
: ". Update DodoSSH, or check that the API address is Spelunk's."),
HttpStatusCode.Unauthorized =>
"Spelunk did not accept the API key. It may have been revoked or mistyped — create a new one in "
+ "Spelunk and paste it into the credential this host uses for it.",
@@ -443,6 +519,17 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
};
}
/// <summary>What a validation problem says about the sign-in key, if it says anything.</summary>
/// <remarks>
/// Matched on the end of the name and without regard to case, because a validator may name the field
/// <c>publicKey</c>, <c>PublicKey</c> or <c>signIn.publicKey</c>, and all three mean this one.
/// </remarks>
private static string? PublicKeyError(SpelunkProblemDocument? problem) =>
problem?.Errors?
.FirstOrDefault(field => field.Key.EndsWith("publicKey", StringComparison.OrdinalIgnoreCase))
.Value?
.FirstOrDefault();
/// <summary>Reads an RFC 7807 body if there is one; null when there is not, or it will not parse.</summary>
private static async Task<SpelunkProblemDocument?> ReadProblemAsync(
HttpResponseMessage response,
@@ -520,9 +607,21 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
}
/// <summary>The body of a ticket request.</summary>
/// <remarks>
/// Either a port or a sign-in, never both: the one not asked for is null and left out of the JSON, so a plain
/// tunnel still asks for exactly <c>{"port":22}</c>.
/// </remarks>
internal sealed class SpelunkTicketRequestDocument
{
public int Port { get; set; }
public int? Port { get; set; }
public SpelunkSignInRequestDocument? SignIn { get; set; }
}
/// <summary>What a sign-in sends: the one-line OpenSSH public key the agent's SSH server is to accept.</summary>
internal sealed class SpelunkSignInRequestDocument
{
public string? PublicKey { get; set; }
}
/// <summary>A ticket as Spelunk returns it. Nullable because it models untrusted input.</summary>
@@ -535,19 +634,26 @@ internal sealed class SpelunkTicketDocument
public string? Path { get; set; }
public string? Subprotocol { get; set; }
/// <summary>The account a sign-in logs in as; absent or null for a plain tunnel.</summary>
public string? User { get; set; }
}
/// <summary>The two fields of an RFC 7807 problem this client repeats.</summary>
/// <summary>The fields of an RFC 7807 problem this client repeats.</summary>
internal sealed class SpelunkProblemDocument
{
public string? Title { get; set; }
public string? Detail { get; set; }
/// <summary>A validation problem's messages by field, as ASP.NET Core writes them.</summary>
public Dictionary<string, string[]>? Errors { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(SpelunkTicketRequestDocument))]
[JsonSerializable(typeof(SpelunkTicketDocument))]