Public Access
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:
1 parent
07bd5c9886
commit
38ab9b431b
18 files changed
+1239
-115
No files matched your search
@@ -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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null
|
||||
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|
||||
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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(
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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",
|
||||
};
|
||||
|
||||
/// <inheritdoc cref="SelectedHostPortIsInherited" />
|
||||
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
|
||||
internal bool SelectedHostUsernameIsInherited =>
|
||||
SelectedHost is { Host.SignsInThroughSpelunk: false, Resolved.Username.IsInherited: true };
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<uint> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A host Spelunk signs in to is answered first, whatever it or its group binds</b> — see
|
||||
/// <see cref="SignedInBySpelunk"/>. It is the one host that connects with no binding, because it says so;
|
||||
/// every other host goes through <see cref="TryBuildBoundAuthentication"/> exactly as before.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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);
|
||||
|
||||
/// <summary>Turns a host's resolved binding into a key, a password or a prompt, or says why it cannot.</summary>
|
||||
/// <remarks>See <see cref="TryBuildAuthentication"/>.</remarks>
|
||||
private bool TryBuildBoundAuthentication(
|
||||
HostSecret host,
|
||||
ResolvedHost resolved,
|
||||
string typedPassword,
|
||||
@@ -12498,6 +12532,27 @@ internal sealed partial class VaultViewModel(
|
||||
out reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How a host Spelunk signs in to authenticates, before the tunnel that decides it is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <see cref="Through"/> — 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.
|
||||
/// </remarks>
|
||||
private static bool SignedInBySpelunk(
|
||||
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||||
out string? reason)
|
||||
{
|
||||
authentication = new HostAuthentication(
|
||||
SpelunkSignIn.DefaultUser,
|
||||
SpelunkSignInPending.Instance,
|
||||
SpelunkSignInIdentity);
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The last thing every branch has to agree on: there is somebody to log in as.
|
||||
/// </summary>
|
||||
@@ -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(
|
||||
|
||||
/// <summary>The picker's selection, if it names something of this kind.</summary>
|
||||
private Guid? Bound(AuthenticationKind kind) =>
|
||||
EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
|
||||
EditorAsksForLogin && EditorSelectedAuthentication is { } choice && choice.Kind == kind
|
||||
? choice.EntityId
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
|
||||
|
||||
Reference in new issue
Block a user