diff --git a/README.md b/README.md index da309bd..5697c6b 100644 --- a/README.md +++ b/README.md @@ -471,12 +471,33 @@ this way; the relay and Spelunk are one choice, and ticking one unticks the othe not take, a key whose role is too low, a server outside the key's organisation, an agent that is not connected, an agent that could not reach the SSH port or is too old to open terminals. +**Sign in through Spelunk (no SSH key needed)**, the box under the key in the same card, lets Spelunk log +you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway +Ed25519 key pair in memory and sends the public half with the ticket request, which the API key authorises; +the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says +which account it logs in as (`root`). It is real SSH to the server's real host key, so shells, file +transfers and port forwards all work, and the key is gone with the connection — it is never stored, +synced or logged. The login and authentication choices are greyed out while it is ticked and saved as +nothing. The server needs OpenSSH's `sshd` installed (not running, and no open port); an agent too old +to sign in, or a server without `sshd`, is said so. Such a host is written at host schema version 10, +so a DodoSSH older than this shows it read-only. + **"Open in DodoSSH" in Spelunk** starts DodoSSH with a `dodossh://spelunk/connect?api=…&server=…&name=…&host=…` -link, or hands the link to the copy already running. The link never carries a credential. If a saved host -already reaches that server through that Spelunk, DodoSSH connects to it; otherwise it opens the host editor -filled in for a new Spelunk host — name, address, API and server — and you choose how to log in and which -credential holds the key, and save. Nothing is ever connected that you have not saved, and a link that -arrives while the vault is locked waits for the unlock. +link, or hands the link to the copy already running, and **connects straight away.** The link never +carries a credential. The hosts links make live in your personal vault, tagged `spelunk`: + +- If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it. +- Otherwise, when a saved host already sends an API key to that same Spelunk, DodoSSH saves a new host in + your personal vault — named as the link names it, at the link's address (or `spelunk-`), port + 22, signing in through Spelunk with that same key credential, tagged `spelunk` (the tag is made if the + vault has none) — and connects to it. It syncs like any host you saved yourself. +- **The first link from a Spelunk** opens the host editor instead, filled in the same way with sign-in + ticked and the tag on, and you choose the credential holding that Spelunk's API key once and save. Every + later link from that Spelunk connects straight away. A link can be sent by anything on this machine, so + an API key is never sent to an address that no saved host already sends it to. + +A host you set up by hand, or one in a team's vault, is left as it is: a link for its server adds a host of +its own. A link that arrives while the vault is locked waits for the unlock. The Windows installer registers the scheme for your user and removes it on uninstall, the macOS bundle declares it in its `Info.plist`, and the Linux package's desktop entry declares `x-scheme-handler/dodossh`. diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml index dbb962f..f657d4e 100644 --- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml @@ -1002,7 +1002,8 @@ FormatString="0" ShowButtonSpinner="False" MinHeight="44" PlaceholderText="{Binding EditorPortPlaceholder}" /> + PlaceholderText="{Binding EditorUsernamePlaceholder}" + IsEnabled="{Binding EditorAsksForLogin}" /> @@ -1016,6 +1017,7 @@ @@ -1028,6 +1030,9 @@ + + + + + + Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save a Spelunk API key with the Admin role as a credential's password and choose it here; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." /> + PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" + IsEnabled="{Binding EditorAsksForLogin}" /> @@ -612,6 +614,15 @@ + + + + + + + Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save a Spelunk API key with the Admin role as a credential's password and choose it here; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." /> diff --git a/src/DodoSSH.Client.Domain/HostSecret.cs b/src/DodoSSH.Client.Domain/HostSecret.cs index fcf7ec3..34e6c4c 100644 --- a/src/DodoSSH.Client.Domain/HostSecret.cs +++ b/src/DodoSSH.Client.Domain/HostSecret.cs @@ -321,6 +321,10 @@ public sealed record HostSecret : IVaultSecret /// public SpelunkRoute? Spelunk { get; init; } + /// Whether Spelunk signs the user in to this host, so it needs no login of its own. + /// See . + public bool SignsInThroughSpelunk => Spelunk?.SignInThroughSpelunk is true; + /// /// Checks the fields that must hold before this can be stored. /// diff --git a/src/DodoSSH.Client.Domain/HostSecretCodec.cs b/src/DodoSSH.Client.Domain/HostSecretCodec.cs index 4c369e5..24097ad 100644 --- a/src/DodoSSH.Client.Domain/HostSecretCodec.cs +++ b/src/DodoSSH.Client.Domain/HostSecretCodec.cs @@ -135,12 +135,28 @@ public static class HostSecretCodec /// public const int SpelunkSchemaVersion = 9; + /// The version that introduced . + /// + /// + /// Its own version, and nested in the existing spelunk object rather than beside it, because it + /// is part of that one decision. A build that knows 9 skips the unknown member and reads a valid host — + /// a Spelunk host with no login of its own — and it must not save that back: the flag would be dropped + /// and the host would start asking for a password it never had. Written at this version, that build + /// shows the host, refuses to edit it, and says to update. + /// + /// + /// Only a route that signs in is written here. Every other Spelunk host stays at + /// , byte for byte what it was. + /// + /// + public const int SpelunkSignInSchemaVersion = 10; + /// The highest schema version this build can write. /// /// Names the highest constant above, which assumes when it takes a /// maximum. A new field added below this line has to be named here too. /// - public const int CurrentSchemaVersion = SpelunkSchemaVersion; + public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion; /// Serialises a host to the bytes that get sealed. /// The host is not valid for storage. @@ -193,14 +209,7 @@ public static class HostSecretCodec : [.. host.PortForwards.Select(PortForwardDocument.From)], // Last of all, and absent for a host that is dialled, so every host without a route encodes // exactly as it did before routes existed. - Spelunk = host.Spelunk is { } route - ? new SpelunkPayloadDocument - { - Api = route.ApiBase, - ServerId = route.ServerId, - ApiKeyCredentialId = route.ApiKeyCredentialId, - } - : null, + Spelunk = host.Spelunk is { } route ? SpelunkPayloadDocument.From(route) : null, }; return JsonSerializer.SerializeToUtf8Bytes( @@ -286,6 +295,11 @@ public static class HostSecretCodec version = Math.Max(version, SpelunkSchemaVersion); } + if (host.SignsInThroughSpelunk) + { + version = Math.Max(version, SpelunkSignInSchemaVersion); + } + return version; } @@ -437,6 +451,9 @@ public static class HostSecretCodec ApiBase = parsed.Api ?? string.Empty, ServerId = parsed.ServerId ?? Guid.Empty, ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty, + + // False folds to the default, as AsksForPassword's does: one state, one spelling. + SignInThroughSpelunk = parsed.SignIn is true, }; } @@ -528,6 +545,23 @@ internal sealed class SpelunkPayloadDocument public Guid? ServerId { get; set; } public Guid? ApiKeyCredentialId { get; set; } + + /// + /// Last, and null unless true, so a route that does not sign in keeps the bytes it had at version 9. + /// See . + /// + public bool? SignIn { get; set; } + + public static SpelunkPayloadDocument From(SpelunkRoute route) => new() + { + Api = route.ApiBase, + ServerId = route.ServerId, + ApiKeyCredentialId = route.ApiKeyCredentialId, + + // Null rather than false, so a route that does not sign in encodes exactly as it did before the + // field existed — the rule AsksForPassword follows. + SignIn = route.SignInThroughSpelunk ? true : null, + }; } /// One forward's serialised shape. Nullable throughout, because it models untrusted input. diff --git a/src/DodoSSH.Client.Domain/SpelunkRoute.cs b/src/DodoSSH.Client.Domain/SpelunkRoute.cs index 4b41ffe..a32810c 100644 --- a/src/DodoSSH.Client.Domain/SpelunkRoute.cs +++ b/src/DodoSSH.Client.Domain/SpelunkRoute.cs @@ -59,6 +59,31 @@ public sealed record SpelunkRoute /// public required Guid ApiKeyCredentialId { get; init; } + /// + /// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own. + /// + /// + /// + /// For each connection this client makes a throwaway key pair in memory and sends the public half with + /// the ticket request, which the API key authorises. The agent then runs a one-connection SSH server on + /// the machine whose only authorised key is that one, and says which account it logs in as. It is real + /// SSH to the server's own host key, so shells, file transfer and port forwards work as for any host, + /// and the key is gone when the connection is. + /// + /// + /// What the host says about logging in is then not asked. Its username, key, credential and + /// typed-password choice — its own or a group's — are left alone rather than refused, because they are + /// the host's answer for the day this is unticked, and refusing a host that carries them would make a + /// merge of two honest edits undecodable. The editor stores no binding beside this, and the connect path + /// never reads one for a host that has it. See VaultViewModel.TryBuildAuthentication. + /// + /// + /// Written from , and only when true, so a route + /// without it encodes exactly as it did before it existed. + /// + /// + public bool SignInThroughSpelunk { get; init; } + /// Whether this is the route to the given server on the given Spelunk. /// A normalised API base, as writes it. /// The server's id in Spelunk. diff --git a/src/DodoSSH.Client.Session/SpelunkLink.cs b/src/DodoSSH.Client.Session/SpelunkLink.cs index 1817953..1abb5d3 100644 --- a/src/DodoSSH.Client.Session/SpelunkLink.cs +++ b/src/DodoSSH.Client.Session/SpelunkLink.cs @@ -18,8 +18,10 @@ namespace DodoSSH.Client.Session; /// /// Anything on the machine can hand this application a link — 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 VaultViewModel.OpenSpelunkLinkAsync. /// /// /// The Spelunk API's base address, normalised as stores it. diff --git a/src/DodoSSH.Client.Session/SpelunkSignIn.cs b/src/DodoSSH.Client.Session/SpelunkSignIn.cs new file mode 100644 index 0000000..9aacf5f --- /dev/null +++ b/src/DodoSSH.Client.Session/SpelunkSignIn.cs @@ -0,0 +1,81 @@ +using System.Text; +using DodoSSH.Client.Ssh; + +namespace DodoSSH.Client.Session; + +/// +/// The throwaway key one connection signs in to a Spelunk server with. +/// +/// +/// +/// For a host set to sign in through Spelunk — see SpelunkRoute.SignInThroughSpelunk — each connection +/// makes one of these, sends with its ticket request, and authenticates with +/// 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. +/// +/// +/// Made in memory and never kept. Nothing writes it to a disk or to the vault, and nothing logs it: +/// says what it is and nothing of what it holds. Ed25519, which is what +/// makes instantly and what every OpenSSH the agent could start accepts. +/// +/// +public sealed class SpelunkSignIn +{ + /// The account Spelunk signs in as when its answer does not name one. + public const string DefaultUser = "root"; + + /// The comment the public key carries, which is what Spelunk and the server's logs see. + internal const string KeyComment = "dodossh-spelunk-sign-in"; + + private SpelunkSignIn(string publicKeyLine, SshPrivateKeyCredential credential) + { + PublicKeyLine = publicKeyLine; + Credential = credential; + } + + /// The public half, as one OpenSSH authorized_keys line: ssh-ed25519 AAAA… comment. + public string PublicKeyLine { get; } + + /// The private half, in the form a connection authenticates with. + public SshPrivateKeyCredential Credential { get; } + + /// Makes a fresh key pair for one connection. + 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)); + } + + /// + /// Says nothing of the key, so one that reaches a log by accident takes nothing with it. + public override string ToString() => "Spelunk sign-in key"; +} + +/// +/// The credential of a connection that will sign in through Spelunk, before the tunnel has made its key. +/// +/// +/// +/// 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. +/// +/// +/// It authenticates nothing. 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. +/// +/// +public sealed record SpelunkSignInPending : SshCredential +{ + /// The one value; there is nothing in it to tell two apart. + public static SpelunkSignInPending Instance { get; } = new(); + + private SpelunkSignInPending() + { + } +} diff --git a/src/DodoSSH.Client.Session/SpelunkTunnel.cs b/src/DodoSSH.Client.Session/SpelunkTunnel.cs index da7f999..7def527 100644 --- a/src/DodoSSH.Client.Session/SpelunkTunnel.cs +++ b/src/DodoSSH.Client.Session/SpelunkTunnel.cs @@ -48,7 +48,10 @@ public static class SpelunkTunnelProtocol /// Close code for an agent that disconnected from Spelunk while the tunnel was open. public const int AgentDisconnectedCloseCode = 4503; - /// What an agent that predates tunnels says, inside a . + /// + /// What an agent that predates tunnels — or, asked to sign in, predates that — says, inside a + /// . + /// internal const string UnknownStreamKind = "Unknown stream kind"; /// Where a ticket for one server is asked for. @@ -67,7 +70,15 @@ public interface ISpelunkTunnel /// The Spelunk API's base address, normalised by . /// The server's id in Spelunk. /// The Spelunk API key, which authorises the ticket and is sent nowhere else. - /// The port the agent is to connect to on the server's own loopback. + /// + /// 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. + /// + /// + /// The throwaway key to have Spelunk sign in with, or null for a plain tunnel to . + /// 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. + /// /// Cancellation. /// /// 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; } /// public SshLoopbackProxy Proxy => bridge.Proxy; + /// The key Spelunk was asked to sign in with, or null for a plain tunnel. + /// + /// 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. + /// + public SpelunkSignIn? SignIn { get; } + + /// + /// The account Spelunk signs in as — its answer's user, or + /// when it named none — or null for a plain tunnel. + /// + public string? User { get; } + /// /// /// 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. /// 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; /// public ValueTask DisposeAsync() => bridge.DisposeAsync(); /// What a close code from Spelunk means, for someone who has to do something about it. - 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 /// The longest ticket accepted. Far beyond Spelunk's; a bound on what goes into a header. private const int MaxTicketLength = 2048; + /// The longest account name taken from Spelunk's answer — well past what a Unix system allows. + private const int MaxUserLength = 64; + /// The longest error body read, so a misbehaving proxy cannot hand this a gigabyte. 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); } /// @@ -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); } } /// Reads the ticket, and refuses one this client would have to take on trust. private static async Task 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 '_'; + /// + /// 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. + /// + /// + /// 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. + /// + private static bool IsAccountName(string user) => + user.Length is > 0 and <= MaxUserLength + && user.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' or '.'); + /// What a refused ticket means, in the words of the status and of Spelunk's own detail. 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 }; } + /// What a validation problem says about the sign-in key, if it says anything. + /// + /// Matched on the end of the name and without regard to case, because a validator may name the field + /// publicKey, PublicKey or signIn.publicKey, and all three mean this one. + /// + private static string? PublicKeyError(SpelunkProblemDocument? problem) => + problem?.Errors? + .FirstOrDefault(field => field.Key.EndsWith("publicKey", StringComparison.OrdinalIgnoreCase)) + .Value? + .FirstOrDefault(); + /// Reads an RFC 7807 body if there is one; null when there is not, or it will not parse. private static async Task ReadProblemAsync( HttpResponseMessage response, @@ -520,9 +607,21 @@ public sealed class SpelunkTunnel : ISpelunkTunnel } /// The body of a ticket request. +/// +/// 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 {"port":22}. +/// internal sealed class SpelunkTicketRequestDocument { - public int Port { get; set; } + public int? Port { get; set; } + + public SpelunkSignInRequestDocument? SignIn { get; set; } +} + +/// What a sign-in sends: the one-line OpenSSH public key the agent's SSH server is to accept. +internal sealed class SpelunkSignInRequestDocument +{ + public string? PublicKey { get; set; } } /// A ticket as Spelunk returns it. Nullable because it models untrusted input. @@ -535,19 +634,26 @@ internal sealed class SpelunkTicketDocument public string? Path { get; set; } public string? Subprotocol { get; set; } + + /// The account a sign-in logs in as; absent or null for a plain tunnel. + public string? User { get; set; } } -/// The two fields of an RFC 7807 problem this client repeats. +/// The fields of an RFC 7807 problem this client repeats. internal sealed class SpelunkProblemDocument { public string? Title { get; set; } public string? Detail { get; set; } + + /// A validation problem's messages by field, as ASP.NET Core writes them. + public Dictionary? Errors { get; set; } } [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] [JsonSerializable(typeof(SpelunkTicketRequestDocument))] [JsonSerializable(typeof(SpelunkTicketDocument))] diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.Spelunk.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.Spelunk.cs index 28614be..181af00 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.Spelunk.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.Spelunk.cs @@ -15,8 +15,8 @@ internal sealed partial class MainWindowViewModel /// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch /// passed down the doorbell, or one the platform delivered to the running application. The link has /// already been checked by ; what it may do is decided by - /// , which never connects to a host the user has not - /// saved. + /// , which never sends an API key to a Spelunk no saved + /// host already sends it to. /// /// /// A locked application keeps it rather than dropping it. Clicking a server in Spelunk and being diff --git a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs index 2ef739b..42cb583 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs @@ -1158,7 +1158,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo internal bool SelectedHostAsksForAPassword => ShowsHostPicker && (SelectedHost is null - || SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword); + || (!SelectedHost.Host.SignsInThroughSpelunk + && SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword)); // ---- The remote pane ---- @@ -1796,14 +1797,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo await CloseSessionAsync().ConfigureAwait(true); if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true) - is not { } opened) + is not ({ } opened, { } dialled)) { return; } session = opened; TypedPassword = string.Empty; - MarkHostConnected(opened, request, row, identityLabel); + MarkHostConnected(opened, dialled, row, identityLabel); reconnectTo = (RemoteKind.Host, row.EntityId); @@ -1818,8 +1819,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo /// /// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not. /// - /// The session, or null with saying why there is none. - private async Task OpenHostSessionAsync( + /// + /// The session and the request it was opened with — which, for a Spelunk sign-in, carries the account + /// Spelunk named rather than the one the host resolved to — or null with saying why + /// there is none. + /// + private async Task<(ISftpSession Session, SshConnectionRequest Request)?> OpenHostSessionAsync( VaultViewModel open, HostRowViewModel row, SshConnectionRequest request, @@ -1833,9 +1838,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo // The session owns the route from here, on success and on failure alike. route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true); - return await sftp - .OpenSftpAsync(route is null ? request : request with { Proxy = route.Proxy }, cancellationToken) - .ConfigureAwait(true); + var through = VaultViewModel.Through(request, route); + + return (await sftp.OpenSftpAsync(through, cancellationToken).ConfigureAwait(true), through); } catch (Exception exception) when (route?.Failure is { } failure diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs index 4958392..87dcc5b 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using DodoSSH.Client.Domain; using DodoSSH.Client.Session; +using DodoSSH.Client.Ssh; namespace DodoSSH.Client.Shell.ViewModels; @@ -16,6 +17,15 @@ namespace DodoSSH.Client.Shell.ViewModels; /// internal sealed partial class VaultViewModel { + /// The tag every host made from a Spelunk link wears, and what a link looks for. + /// + /// Matched without regard to case, as the editor matches every tag name — see AddEditorTagAsync. + /// + internal const string SpelunkTag = "spelunk"; + + /// What the status bar and the log name as the identity of a Spelunk sign-in. + private const string SpelunkSignInIdentity = "Spelunk sign-in"; + /// What opens a tunnel when no caller supplied something else. /// /// Static and shared for the reason is: it holds nothing but a @@ -32,8 +42,18 @@ internal sealed partial class VaultViewModel /// fact what these two boxes can simply not express. /// [ObservableProperty] + [NotifyPropertyChangedFor(nameof(EditorAsksForLogin))] private bool editorThroughSpelunk; + /// Whether Spelunk signs the user in to the host being edited, so it needs no login of its own. + /// + /// Inside the Spelunk card, and meaningful only while that is ticked. See + /// . + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(EditorAsksForLogin))] + private bool editorSpelunkSignIn; + /// The Spelunk API address in the editor, as typed. [ObservableProperty] private string editorSpelunkApi = string.Empty; @@ -54,6 +74,16 @@ internal sealed partial class VaultViewModel [ObservableProperty] private AuthenticationChoice? editorSelectedSpelunkKey; + /// + /// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to. + /// + /// + /// The two controls are disabled rather than hidden while it is false, with a sentence saying why, so a + /// login somebody had chosen is still there to see — and comes back as it was if the box is unticked + /// before saving. + /// + internal bool EditorAsksForLogin => !(EditorThroughSpelunk && EditorSpelunkSignIn); + /// private ISpelunkTunnel Spelunk => spelunk ?? DefaultSpelunk; @@ -87,6 +117,10 @@ internal sealed partial class VaultViewModel /// for the relay's half of that argument; Spelunk's is stronger still, /// because a Spelunk host's address may be nothing that answers at all. /// + /// + /// The request a route is used for is made with , which is where a Spelunk sign-in's + /// key and account replace the ones the host resolved to. + /// /// internal async Task OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken) { @@ -102,6 +136,39 @@ internal sealed partial class VaultViewModel : null; } + /// + /// The request to connect with once a route is open: through its proxy, and for a Spelunk sign-in, with + /// the key the tunnel was opened with and the account Spelunk named. + /// + /// + /// + /// Both transports make their request through this, so a sign-in cannot reach one of them with the + /// stand-in credential gives it — which the SSH layer would refuse + /// before dialling anyway. See . + /// + /// + /// The host and port are left alone. The host key is pinned under them as for every host, and Spelunk's + /// SSH server presents the server's real host key, so a sign-in and a plain tunnel to one server are + /// checked against the same pin. + /// + /// + internal static SshConnectionRequest Through(SshConnectionRequest request, IConnectionRoute? route) + { + ArgumentNullException.ThrowIfNull(request); + + return route switch + { + null => request, + SpelunkTunnelRoute { SignIn: { } signIn, User: { } user } => request with + { + Proxy = route.Proxy, + Username = user, + Credential = signIn.Credential, + }, + _ => request with { Proxy = route.Proxy }, + }; + } + /// /// Opens a Spelunk tunnel to a host, with the key its credential holds. /// @@ -115,6 +182,12 @@ internal sealed partial class VaultViewModel /// The port is the host's resolved one, group and all: it is the port the agent connects to on the /// server's own loopback, which is where the SSH server listens whatever address the world sees. /// + /// + /// A host Spelunk signs in to gets a new key pair here, every time. It is made in memory for this + /// one tunnel, its public half goes with the ticket request, and the route carries it to the connection — + /// see . A retry after a host key question, or a reconnect, comes back through + /// here and makes another. + /// /// private async Task OpenSpelunkTunnelAsync( HostRowViewModel row, @@ -129,7 +202,13 @@ internal sealed partial class VaultViewModel } return await Spelunk - .OpenAsync(route.ApiBase, route.ServerId, key.Credential.Password, row.Resolved.Port.Value, cancellationToken) + .OpenAsync( + route.ApiBase, + route.ServerId, + key.Credential.Password, + row.Resolved.Port.Value, + route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null, + cancellationToken) .ConfigureAwait(true); } @@ -137,66 +216,243 @@ internal sealed partial class VaultViewModel /// Does what a dodossh://spelunk/connect link asks, as far as it is allowed to. /// /// - /// The connection attempt when a saved host was found, so a caller that wants to can wait for it; a - /// completed task otherwise. + /// The work the link started, connection attempt included, so a caller that wants to can wait for it. /// /// /// - /// A saved host for that server on that Spelunk is connected to, exactly as a tap on its row - /// would — a password prompt if it asks for one, a host key question if it has not been seen. + /// A host for that server in the personal vault, tagged , is connected to, + /// exactly as a tap on its row would. Those are the hosts links make, and the only ones a link looks for: + /// a host somebody set up by hand, or one in a team's vault, is theirs to connect to and not the link's. /// /// - /// Otherwise the editor opens on a new host, and nothing more. The link came from outside, and - /// anything on this machine can make one; it must never be able to make this application connect - /// somewhere, or send an API key somewhere, that the user has not looked at and saved. So the host is - /// pre-filled — the name, the address its key will be pinned under, the Spelunk and the server — and - /// the user chooses how to log in and which credential holds the key, and presses SAVE. + /// Otherwise, when that Spelunk already gets an API key, the host is made and connected to. In the + /// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no + /// SSH key or password — with the key credential a saved host already sends to that same Spelunk. It is + /// saved the ordinary way and syncs like any host. Nothing leaks by this: the key goes where it already + /// goes, and the server is one that key's own organisation holds or Spelunk refuses it. /// /// - /// The key is pre-chosen only when another host already sends it to the same Spelunk. That is a - /// convenience that cannot leak anything — the key already goes there — whereas guessing a key for a - /// Spelunk no host uses would be offering to send it to whatever address the link named. + /// The first link from a Spelunk opens the editor instead, and nothing more. Anything on this + /// machine can make a link, so it must never be able to send an API key to an address no saved host + /// already sends it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal + /// vault — and the user chooses the credential holding the key and saves; every later link from that + /// Spelunk connects straight away. /// /// - internal Task OpenSpelunkLinkAsync(SpelunkLink link) + internal async Task OpenSpelunkLinkAsync(SpelunkLink link) { ArgumentNullException.ThrowIfNull(link); - if (Hosts.FirstOrDefault(row => row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true) is { } saved) + var personal = PersonalVaultId(); + + if (personal is { } vaultId + && Hosts.FirstOrDefault(row => + row.VaultId == vaultId + && row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true + && WearsSpelunkTag(row.Host)) is { } saved) { - return ConnectToRowCommand.ExecuteAsync(saved); + await ConnectToRowCommand.ExecuteAsync(saved).ConfigureAwait(true); + return; } + var knownKey = KnownSpelunkKey(link.ApiBase, personal); + + if (personal is { } into && knownKey is { } key) + { + await AddFromLinkAndConnectAsync(link, into, key).ConfigureAwait(true); + return; + } + + await OpenLinkInEditorAsync(link, personal, knownKey).ConfigureAwait(true); + } + + /// The personal vault, when this session can read and write it. + private Guid? PersonalVaultId() => + session.ReadableVaults.FirstOrDefault(vault => vault.IsPersonal && vault.CanWrite)?.VaultId; + + /// Whether a host wears a tag called , from whichever vault. + private bool WearsSpelunkTag(HostSecret host) => + host.TagIds.Any(id => tagsById.TryGetValue(id, out var tag) && IsSpelunkTag(tag)); + + private static bool IsSpelunkTag(TagSecret tag) => + string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase); + + /// + /// The credential a saved host already sends to this Spelunk as its API key, or null when none does. + /// + /// + /// The personal vault's hosts first, so the answer does not depend on the order vaults happen to load in; + /// and only a credential still in the keychain, because a dangling one would make a host that cannot + /// connect. + /// + private Guid? KnownSpelunkKey(string apiBase, Guid? personal) => + Hosts + .OrderByDescending(row => row.VaultId == personal) + .Select(row => row.Host.Spelunk) + .Where(route => route is not null && string.Equals(route.ApiBase, apiBase, StringComparison.Ordinal)) + .Select(route => (Guid?)route!.ApiKeyCredentialId) + .FirstOrDefault(id => Credentials.Any(credential => credential.EntityId == id)); + + /// The host a link makes: what the link says, signing in through Spelunk with a known key. + private static HostSecret HostFromLink(SpelunkLink link, Guid apiKeyCredentialId, TagSet tags) => new() + { + Label = link.Name, + Hostname = link.Hostname, + Port = HostSecret.DefaultPort, + TagIds = tags, + Spelunk = new SpelunkRoute + { + ApiBase = link.ApiBase, + ServerId = link.ServerId, + ApiKeyCredentialId = apiKeyCredentialId, + SignInThroughSpelunk = true, + }, + }; + + /// Saves the host a link describes into the personal vault, tagged, and connects to it. + private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId, Guid apiKeyCredentialId) + { + if (IsBusy) + { + // RunAsync would drop it without a word, and the user pressed a button somewhere else to get here. + Status = $"Busy — open {link.Name} from Spelunk again in a moment."; + return; + } + + Guid? hostId = null; + + await RunAsync( + $"Adding {link.Name} from Spelunk…", + async () => + { + var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true); + + hostId = await session.Hosts + .CreateAsync( + vaultId, + HostFromLink(link, apiKeyCredentialId, TagSet.Create([tagId])), + CancellationToken.None) + .ConfigureAwait(true); + + await ReloadAsync(CancellationToken.None).ConfigureAwait(true); + }).ConfigureAwait(true); + + // A failure has said so on the status line already. + if (hostId is not { } id || Hosts.FirstOrDefault(row => row.EntityId == id) is not { } row) + { + return; + } + + Status = $"Added {link.Name} from Spelunk at {link.ApiBase}."; + + await ConnectToRowCommand.ExecuteAsync(row).ConfigureAwait(true); + + // After the connect rather than before it: the host is saved either way, and the terminal is what the + // user is waiting for. As SaveHostAsync does, it goes to the server now rather than at the next tick. + await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true); + } + + /// + /// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to. + /// + /// + /// Opened before anything is awaited, so the editor is up in the same turn the link arrived in. The tag is + /// made or found afterwards and added to the host being edited; a tag made for a host that is then + /// cancelled stays, as the editor's own new-tag box leaves it. + /// + private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal, Guid? knownKey) + { // Refused while another host is half-edited, as NewHost refuses it, and said the same way: the link // is not worth somebody's unsaved host. if (AHostEditorIsInTheWay()) { - return Task.CompletedTask; + return; } NewHost(); + if (personal is { } vaultId + && EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId) is { } choice) + { + EditorSelectedVault = choice; + } + EditorLabel = link.Name; EditorHostname = link.Hostname; EditorPort = HostSecret.DefaultPort; EditorThroughSpelunk = true; + EditorSpelunkSignIn = true; EditorSpelunkApi = link.ApiBase; EditorSpelunkServerId = link.ServerId.ToString(); - var knownKey = Hosts - .Select(row => row.Host.Spelunk) - .FirstOrDefault(route => route is not null && string.Equals(route.ApiBase, link.ApiBase, StringComparison.Ordinal)) - ?.ApiKeyCredentialId; - - BuildSpelunkKeyChoices(Credentials.Any(credential => credential.EntityId == knownKey) ? knownKey : null); + BuildSpelunkKeyChoices(knownKey); IsHostPaneOpen = true; - Status = EditorSelectedSpelunkKey is null - ? $"Adding {link.Name} from Spelunk at {link.ApiBase}. Choose how to log in and the credential " - + "holding your Spelunk API key, then save." - : $"Adding {link.Name} from Spelunk at {link.ApiBase}. Choose how to log in, then save."; - return Task.CompletedTask; + var said = EditorSelectedSpelunkKey is null + ? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose the credential " + + "holding your Spelunk API key and save — Spelunk signs you in, so no SSH key is needed, and " + + "later links from this Spelunk connect straight away." + : $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save."; + + Status = said; + + if (personal is { } into) + { + await TagHostFromLinkAsync(into, said).ConfigureAwait(true); + } + } + + /// Adds the tag to the host a link opened the editor on. + /// The personal vault, where the host is to be saved and the tag is. + /// What the status line says about the link, put back after the reload. + private async Task TagHostFromLinkAsync(Guid vaultId, string said) + { + try + { + var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true); + + // Only onto the host this link opened: the user may have cancelled it, or moved on to another, in + // the moment the tag took. + if (!IsEditing || editingEntityId is not null || editingHostVaultId != vaultId) + { + return; + } + + editorTagIds = TagSet.Create([.. editorTagIds, tagId]); + + if (!tagsById.ContainsKey(tagId)) + { + await ReloadAsync(CancellationToken.None).ConfigureAwait(true); + } + + BuildTagChoices(); + Status = said; + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + // The host can still be saved; it is the tag that could not be made, and a later link would then + // not find it. Said, rather than failing the whole link over it. + Status = $"{said} (The '{SpelunkTag}' tag could not be added: {exception.Message})"; + } + } + + /// The tag in a vault, made there if it has none. + private async Task EnsureSpelunkTagAsync(Guid vaultId, CancellationToken cancellationToken) + { + var listing = await session.Tags.ListAsync(vaultId, cancellationToken).ConfigureAwait(true); + + if (listing.Items + .Where(tag => IsSpelunkTag(tag.Secret)) + .OrderBy(tag => tag.EntityId) + .FirstOrDefault() is { } existing) + { + return existing.EntityId; + } + + return await session.Tags + .CreateAsync(vaultId, new TagSecret { Label = SpelunkTag }, cancellationToken) + .ConfigureAwait(true); } /// @@ -207,15 +463,20 @@ internal sealed partial class VaultViewModel /// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask, /// so a credential that is only somebody's Spelunk key is not deleted as though nothing used it. Spelunk /// keys are named by the host itself and never inherited, so the stored route is the whole answer there. + /// A host Spelunk signs in to uses no binding at all, whatever its group lends. /// private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) => - (row.Resolved.Binding is { } binding && binding.Kind == kind && binding.EntityId == entityId) + (!row.Host.SignsInThroughSpelunk + && row.Resolved.Binding is { } binding + && binding.Kind == kind + && binding.EntityId == entityId) || (kind is ResolvedBindingKind.Credential && row.Host.Spelunk?.ApiKeyCredentialId == entityId); /// Puts a host's route into the editor, or clears it for a host that has none. private void LoadEditorSpelunk(SpelunkRoute? route) { EditorThroughSpelunk = route is not null; + EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false; EditorSpelunkApi = route?.ApiBase ?? string.Empty; EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty; @@ -261,6 +522,7 @@ internal sealed partial class VaultViewModel : EditorSpelunkApi.Trim(), ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty, ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty, + SignInThroughSpelunk = EditorSpelunkSignIn, }; } } diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs index a0bc153..ccb4111 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs @@ -4308,10 +4308,15 @@ internal sealed partial class VaultViewModel( /// and now means "take the group's" — so reading the ids directly would put a password box in front of /// every host under a group that binds a key, and the box would do nothing. /// + /// + /// False for a host Spelunk signs in to, whatever it resolves to: there is nothing to type, and a box — + /// or on the phone, a sheet — asking for a password there would be a question with no use for its answer. + /// /// internal bool SelectedHostAsksForAPassword => SelectedHost is null - || SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword; + || (!SelectedHost.Host.SignsInThroughSpelunk + && SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword); /// /// What the terminal column says in place of the password box, or nothing when the box is showing. @@ -4330,11 +4335,13 @@ internal sealed partial class VaultViewModel( /// changed. /// /// - internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch + internal string SelectedHostAuthenticationNote => SelectedHost switch { - { Kind: ResolvedBindingKind.Credential } binding => + { Host.SignsInThroughSpelunk: true } => + "Spelunk signs you in to this host with a one-time key; no SSH key or password is needed.", + { Resolved.Binding: { Kind: ResolvedBindingKind.Credential } binding } => $"This host uses a password stored in your keychain{From(binding)}.", - { Kind: ResolvedBindingKind.SshKey } binding => + { Resolved.Binding: { Kind: ResolvedBindingKind.SshKey } binding } => $"This host authenticates with an SSH key{From(binding)}.", _ => string.Empty, }; @@ -4363,14 +4370,19 @@ internal sealed partial class VaultViewModel( /// /// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts /// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a - /// value that had not loaded. + /// value that had not loaded. A host Spelunk signs in to logs in as whatever Spelunk names, so it says + /// that rather than a username the connection will not use. /// - internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user - ? user - : "no account set"; + internal string SelectedHostUsernameLabel => SelectedHost switch + { + { Host.SignsInThroughSpelunk: true } => "named by Spelunk", + { Resolved.Username.Value: { Length: > 0 } user } => user, + _ => "no account set", + }; /// - internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false; + internal bool SelectedHostUsernameIsInherited => + SelectedHost is { Host.SignsInThroughSpelunk: false, Resolved.Username.IsInherited: true }; /// /// What the pane names in the credentials row: the key or password this host authenticates with. @@ -4386,11 +4398,12 @@ internal sealed partial class VaultViewModel( /// the reference is allowed to dangle, and a GUID in a field is not an answer to anything. /// /// - internal string SelectedHostBindingLabel => SelectedHost?.Resolved.Binding switch + internal string SelectedHostBindingLabel => SelectedHost switch { - { Kind: ResolvedBindingKind.SshKey, EntityId: { } id } => + { Host.SignsInThroughSpelunk: true } => SpelunkSignInIdentity, + { Resolved.Binding: { Kind: ResolvedBindingKind.SshKey, EntityId: { } id } } => Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)", - { Kind: ResolvedBindingKind.Credential, EntityId: { } id } => + { Resolved.Binding: { Kind: ResolvedBindingKind.Credential, EntityId: { } id } } => Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label ?? "(a password that is no longer here)", _ => string.Empty, @@ -11991,7 +12004,11 @@ internal sealed partial class VaultViewModel( authentication.Username, authentication.Credential); - var sessionId = await OpenThroughAsync(target, request, reporter, cancellationToken).ConfigureAwait(true); + var (sessionId, dialled) = await OpenThroughAsync(target, request, reporter, cancellationToken) + .ConfigureAwait(true); + + // The account the session actually logged in as, which a Spelunk sign-in learns only from the tunnel. + authentication = authentication with { Username = dialled.Username }; // The workspace has already opened a ticket for this session, with the address and the moment it // connected. What it could not know is which keychain item this was — an SshConnectionRequest has no @@ -12086,7 +12103,7 @@ internal sealed partial class VaultViewModel( /// bridge listening until its own timeout; disposing twice is safe. /// /// - private async Task OpenThroughAsync( + private async Task<(uint SessionId, SshConnectionRequest Request)> OpenThroughAsync( ConnectionTarget target, SshConnectionRequest request, PhaseReporter reporter, @@ -12098,15 +12115,15 @@ internal sealed partial class VaultViewModel( ? await OpenRouteAsync(row, cancellationToken).ConfigureAwait(true) : null; + var through = Through(request, route); + try { - return await workspace - .OpenSessionAsync( - route is null ? request : request with { Proxy = route.Proxy }, - TerminalSize.Default, - reporter, - cancellationToken) + var sessionId = await workspace + .OpenSessionAsync(through, TerminalSize.Default, reporter, cancellationToken) .ConfigureAwait(true); + + return (sessionId, through); } catch (Exception exception) when (route is not null) { @@ -12432,8 +12449,25 @@ internal sealed partial class VaultViewModel( /// authenticates with a key that is not in this keychain any more" would go looking at a host that says /// nothing about keys. /// + /// + /// A host Spelunk signs in to is answered first, whatever it or its group binds — see + /// . It is the one host that connects with no binding, because it says so; + /// every other host goes through exactly as before. + /// /// private bool TryBuildAuthentication( + HostSecret host, + ResolvedHost resolved, + string typedPassword, + [NotNullWhen(true)] out HostAuthentication? authentication, + [NotNullWhen(false)] out string? reason) => + host.SignsInThroughSpelunk + ? SignedInBySpelunk(out authentication, out reason) + : TryBuildBoundAuthentication(host, resolved, typedPassword, out authentication, out reason); + + /// Turns a host's resolved binding into a key, a password or a prompt, or says why it cannot. + /// See . + private bool TryBuildBoundAuthentication( HostSecret host, ResolvedHost resolved, string typedPassword, @@ -12498,6 +12532,27 @@ internal sealed partial class VaultViewModel( out reason); } + /// + /// How a host Spelunk signs in to authenticates, before the tunnel that decides it is open. + /// + /// + /// Spelunk signs the host in with a key the tunnel makes and an account Spelunk names, neither of which + /// exists yet. These stand-ins are replaced as the connection is made through the route — see + /// — and refused by the SSH layer if they ever reach it. It is the only way to + /// connect without a binding, and only for a host that says so; every other host still needs one. + /// + private static bool SignedInBySpelunk( + [NotNullWhen(true)] out HostAuthentication? authentication, + out string? reason) + { + authentication = new HostAuthentication( + SpelunkSignIn.DefaultUser, + SpelunkSignInPending.Instance, + SpelunkSignInIdentity); + reason = null; + return true; + } + /// /// The last thing every branch has to agree on: there is somebody to log in as. /// @@ -12570,7 +12625,9 @@ internal sealed partial class VaultViewModel( // an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the // absence of a decision rather than one, and storing it as a decision would pin every ungrouped // host in the vault the first time it was edited. - AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed + // Never beside a Spelunk sign-in, nor are the bindings below: a host has one way to log in. + AsksForPassword = EditorAsksForLogin + && EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed && EditorSelectedGroup?.EntityId is not null ? true : null, @@ -12605,7 +12662,9 @@ internal sealed partial class VaultViewModel( /// The picker's selection, if it names something of this kind. private Guid? Bound(AuthenticationKind kind) => - EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null; + EditorAsksForLogin && EditorSelectedAuthentication is { } choice && choice.Kind == kind + ? choice.EntityId + : null; /// /// Fills the authentication picker, keeping whatever the host is currently bound to selectable. diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs index ba6c67a..ccc44b0 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs @@ -20,8 +20,9 @@ namespace DodoSSH.Client.App.Tests; /// /// /// 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. /// /// 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(); + + 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(); + 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; } + /// A saved host through Spelunk that Spelunk signs in to: no username, no key, no password. + private async Task 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; } = []; + /// The sign-in key each tunnel was asked for, in order; null for a plain tunnel. + internal List SignIns { get; } = []; + + /// The account a sign-in answer names. + internal string User { get; set; } = "root"; + internal SpelunkTunnelException? Refusal { get; set; } /// A close Spelunk sends as soon as the tunnel is open, or null to leave it open. @@ -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) diff --git a/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs b/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs index 6c35822..6079859 100644 --- a/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs +++ b/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs @@ -45,9 +45,12 @@ internal static class HostFactory /// A server's id in Spelunk. internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d"); - /// A route to , with . - internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com") => - new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey }; + /// + /// A route to , with — signing in through Spelunk + /// when says so. + /// + internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com", bool signIn = false) => + new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey, SignInThroughSpelunk = signIn }; /// /// defaults to an explicit 22 rather than to null, so a test that says nothing diff --git a/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs b/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs index 3a94ff0..46ad6d2 100644 --- a/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs +++ b/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs @@ -18,7 +18,8 @@ public sealed class HostSecretCodecTests /// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may /// carry a key or a credential and never both, and neither may sit beside AsksForPassword. This /// one carries the credential, because that is the newer of the two, plus a group, a pair of tags, a - /// pair of pinned paths, a pair of forwards and a Spelunk route — which are orthogonal to the binding and + /// pair of pinned paths, a pair of forwards and a Spelunk route that signs in — which are orthogonal to the + /// binding (a sign-in leaves a stored binding alone rather than refusing it) and /// are what make this host reach the highest schema version a valid host can. The route and the relay are /// exclusive in the same way the bindings are, so this carries the newer of those two as well; the relay's /// flag has its own tests. @@ -41,7 +42,7 @@ public sealed class HostSecretCodecTests tags: [Pci, EuWest], pinnedPaths: ["/var/www/app", "/var/log"], portForwards: [Postgres, Socks], - spelunk: ThroughSpelunk()); + spelunk: ThroughSpelunk(signIn: true)); HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); diff --git a/tests/DodoSSH.Client.Domain.Tests/SpelunkRouteTests.cs b/tests/DodoSSH.Client.Domain.Tests/SpelunkRouteTests.cs index 42b6199..8910ef7 100644 --- a/tests/DodoSSH.Client.Domain.Tests/SpelunkRouteTests.cs +++ b/tests/DodoSSH.Client.Domain.Tests/SpelunkRouteTests.cs @@ -99,7 +99,7 @@ public sealed class SpelunkRouteTests document.ShouldNotBeNull(); document.Host.ShouldBe(host); document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion); - document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion); + document.IsReadOnly.ShouldBeFalse(); } [Fact] @@ -156,10 +156,10 @@ public sealed class SpelunkRouteTests public void ARouteWrittenByANewerClient_IsReadOnlyHere() { // The rule every version before this one relies on, checked from the far side: a build that knows - // version 9 reads a version 10 host, shows it, and refuses to re-encode what it cannot represent. + // version 10 reads a version 11 host, shows it, and refuses to re-encode what it cannot represent. var payload = Encoding.UTF8.GetBytes( """ - {"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true} + {"schemaVersion":11,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true} """); HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); @@ -168,6 +168,108 @@ public sealed class SpelunkRouteTests document.Host.Spelunk.ShouldBe(ThroughSpelunk()); } + // ---- Signing in through Spelunk ---- + + [Fact] + public void AHostSpelunkSignsInTo_IsValid_WithNoLoginOfItsOwn() + { + // No username, no key, no credential, no typed password: Spelunk names the account and makes the key. + var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true)); + + host.TryValidate(out var reason).ShouldBeTrue(reason); + host.SignsInThroughSpelunk.ShouldBeTrue(); + } + + [Fact] + public void AHostSpelunkSignsInTo_KeepsALoginItStillCarries() + { + // Left alone rather than refused: it is the host's answer for the day the box is unticked, and a + // merge of one machine ticking it with another binding a key must still decode. + Host(sshKeyId: Guid.CreateVersion7(), spelunk: ThroughSpelunk(signIn: true)) + .TryValidate(out var reason) + .ShouldBeTrue(reason); + } + + [Fact] + public void AHostReachedThroughSpelunkWithoutSigningIn_DoesNotSignIn() + { + Host(spelunk: ThroughSpelunk()).SignsInThroughSpelunk.ShouldBeFalse(); + Host().SignsInThroughSpelunk.ShouldBeFalse(); + } + + [Fact] + public void ASignIn_RoundTrips_AtTheVersionThatIntroducedIt() + { + var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true)); + + HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.Host.ShouldBe(host); + document.Host.Spelunk.ShouldNotBeNull().SignInThroughSpelunk.ShouldBeTrue(); + document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignInSchemaVersion); + document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion); + document.IsReadOnly.ShouldBeFalse(); + } + + [Fact] + public void ASignIn_IsWrittenInsideTheRoute_AndOnlyWhenOn() + { + var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, spelunk: ThroughSpelunk(signIn: true))); + + Encoding.UTF8.GetString(bytes).ShouldBe( + """ + {"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c","signIn":true}} + """); + } + + [Fact] + public void ARouteWrittenBeforeSignIn_DecodesAsNotSigningIn_AndStaysEditable() + { + var payload = Encoding.UTF8.GetBytes( + """ + {"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}} + """); + + HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); + + document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk()); + document.Host.SignsInThroughSpelunk.ShouldBeFalse(); + document.IsReadOnly.ShouldBeFalse(); + } + + [Fact] + public void ASignInWrittenAsFalse_ReadsAsTheRouteWithout() + { + // One state, one spelling — the merge would otherwise see a change nobody made. + var payload = Encoding.UTF8.GetBytes( + """ + {"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c","signIn":false}} + """); + + HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); + + document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk()); + } + + [Fact] + public void TickingSignIn_MergesAsPartOfTheOneRoute() + { + var ancestor = Host(spelunk: ThroughSpelunk()); + var local = ancestor with { Spelunk = ThroughSpelunk(signIn: true) }; + var remote = ancestor with { Spelunk = ThroughSpelunk() with { ServerId = Guid.CreateVersion7() } }; + + // Alone, the tick arrives. + HostSecretMerge.Merge(ancestor, local, ancestor).Merged.ShouldBe(local); + + // Beside a different edit to the route, the route is still one value: one side wins whole, with the + // other reported, rather than a sign-in to a server nobody chose it for. + var result = HostSecretMerge.Merge(ancestor, local, remote); + + result.Merged.Spelunk.ShouldBe(remote.Spelunk); + result.Conflicts.ShouldHaveSingleItem().Field.ShouldBe(nameof(HostSecret.Spelunk)); + } + // ---- The merge ---- [Fact] diff --git a/tests/DodoSSH.Client.Session.Tests/SpelunkTunnelTests.cs b/tests/DodoSSH.Client.Session.Tests/SpelunkTunnelTests.cs index 2e3d459..4b24b43 100644 --- a/tests/DodoSSH.Client.Session.Tests/SpelunkTunnelTests.cs +++ b/tests/DodoSSH.Client.Session.Tests/SpelunkTunnelTests.cs @@ -205,7 +205,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent() { var refusal = await Should.ThrowAsync( - 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( - 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( + 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( + 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"); + } + + /// A ticket answer for a sign-in, naming the account it logs in as. + private static Func 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 OpenAsync() => spelunk.ApiBase.OpenAsync(port: 22); /// Speaks SOCKS5 to the bridge as SSH.NET would, and hands back the open stream. @@ -273,11 +392,12 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime /// Opens a tunnel to the test server with the one key and server the suite uses. file static class Opening { - internal static Task OpenAsync(this string apiBase, int port) => + internal static Task 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); }