diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml index c1fcbe6..1a8d0e8 100644 --- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml @@ -418,6 +418,10 @@ @@ -426,6 +430,12 @@ + + + + diff --git a/src/DodoSSH.Client.App/Views/HostsScreen.axaml b/src/DodoSSH.Client.App/Views/HostsScreen.axaml index e69e949..4ead5a4 100644 --- a/src/DodoSSH.Client.App/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.App/Views/HostsScreen.axaml @@ -36,14 +36,23 @@ a key wants nothing typed here — and a sentence in its place when it does not, because "nothing needs typing" and "something needs typing and the box has not appeared yet" look identical and only one of them is fine. + + REMEMBER travels with the box and hides with it. It is the two-step chore the box's tooltip used to + describe — add a password under Keychain, then bind the host to it — done from the one screen that + already has the password, and it takes effect only once the remote has accepted it. --> - + ToolTip.Tip="Typed each time unless REMEMBER is ticked, in which case it is saved to your keychain and bound to this host once the connection succeeds." /> + + + diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs index 649a0f8..4d9dc0c 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs @@ -333,7 +333,13 @@ internal sealed partial class HostRowViewModel( /// What a host can authenticate with. internal enum AuthenticationKind { - /// Typed at the moment of connecting, and never stored. + /// Typed at the moment of connecting. + /// + /// Nothing is stored under this kind. Ticking the connect bar's REMEMBER does not change that — it + /// creates a credential and moves the host to , so a stored password is always + /// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See + /// . + /// Typed, /// An SSH key in this vault. @@ -1984,14 +1990,36 @@ internal sealed partial class VaultViewModel( // ---- Connecting ---- /// - /// Typed per connection, never persisted, and now only reached by a host bound to nothing. It stays because - /// not every password is worth storing — a one-off on a machine somebody will never open again, or one - /// they would rather this vault did not hold — and because a credential has to be created before it can be - /// bound, which means the first connection to a new host happens through this box. + /// Typed per connection, not persisted unless says otherwise, and + /// only reached by a host bound to nothing. It stays because not every password is worth storing — a + /// one-off on a machine somebody will never open again, or one they would rather this vault did not hold + /// — and because a credential has to be created before it can be bound, which means the first connection + /// to a new host happens through this box. /// [ObservableProperty] private string connectPassword = string.Empty; + /// + /// Whether a password typed here should be kept, so this host stops asking for it. + /// + /// + /// + /// What it produces is an ordinary keychain credential bound to the host, and not a fourth place a + /// password can live. The two-step chore it replaces — add a password under Keychain, then open the host + /// and bind it — is what the box's tooltip used to instruct people to do by hand, and doing it by hand + /// means typing the secret into a second screen while the first one already has it. + /// + /// + /// Off by default, and it stays a decision. The reason a typed password exists at all is that not + /// every password belongs in a synchronised vault; remembering silently would move each of them there and + /// tell nobody. It also only takes effect once the handshake has succeeded — see + /// — because a password that has just been refused is precisely + /// the one not worth keeping. + /// + /// + [ObservableProperty] + private bool remembersConnectPassword; + /// /// Whether the selected host will want something typed into the password box. /// @@ -5231,6 +5259,95 @@ internal sealed partial class VaultViewModel( sessionId, row.Label, Dialled(row, authentication))); + + // Last, and after the tab exists: keeping the password is a favour, and the session the user asked + // for must not wait on a vault write to appear. + await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true); + } + + /// + /// Turns the password that just worked into a keychain credential bound to this host. + /// + /// + /// + /// Only after a handshake the remote accepted. Storing a password the moment it is typed would + /// bind whatever was in the box — including the typo that is about to be refused — and the host would + /// then stop asking, leaving a machine that cannot be connected to until somebody works out that the + /// keychain is where the wrong password now lives. + /// + /// + /// A credential rather than a field on the host, which is why nothing else here had to change. + /// It syncs, merges, appears in the keychain, can be renamed, deleted and — the reason the item type + /// exists — bound to the other nineteen machines that share the account. See + /// on why the binding is an id and not a copy. + /// + /// + /// The credential carries no username of its own, so it keeps taking the host's — which is what the + /// connection that just succeeded did. Copying the resolved username into it would pin whatever the + /// group happened to say at this moment, and quietly stop following the group afterwards. + /// + /// + /// Every failure is reported and swallowed. The caller's catch blocks describe a connection that + /// did not happen, and this one did: a vault write that fails here must not tell the user their terminal + /// was abandoned, and a cancellation must not report it as cancelled. + /// + /// + private async Task RememberTypedPasswordAsync( + HostRowViewModel row, + HostAuthentication authentication, + CancellationToken cancellationToken) + { + // The password as dialled, not as the box currently reads: the two can differ by now, because a + // handshake takes time and the box stays typeable throughout it. + if (!RemembersConnectPassword + || row.Resolved.Binding.Kind is not ResolvedBindingKind.TypedPassword + || authentication.Credential is not SshPasswordCredential { Password.Length: > 0 } typed) + { + return; + } + + if (row.IsReadOnly) + { + Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a " + + "newer version of DodoSSH, and binding a credential would re-encode it."; + return; + } + + var credential = new CredentialSecret { Label = row.Label, Password = typed.Password }; + + try + { + var credentialId = await session.Credentials + .CreateAsync(row.VaultId, credential, cancellationToken) + .ConfigureAwait(true); + + // Into the same vault as the host, deliberately: a credential in the personal vault bound to a + // team's host is a binding every other member can see and none of them can resolve. + await session.Hosts + .UpdateAsync( + row.VaultId, + row.EntityId, + row.Host with { CredentialId = credentialId, AsksForPassword = null }, + cancellationToken) + .ConfigureAwait(true); + } + catch (Exception exception) + { + Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}"; + return; + } + + // Cleared together. The box is about to disappear — the host answers "credential" now — and a tick + // left behind would apply to the next host somebody selects. + RemembersConnectPassword = false; + ConnectPassword = string.Empty; + + await ReloadAsync(cancellationToken).ConfigureAwait(true); + + Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it " + + "will not be asked for again."; + + await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// The address as actually dialled. diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs index 750805c..bb5d9c5 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs @@ -2590,6 +2590,123 @@ public sealed class ShellFlowTests : IAsyncLifetime vault.Hosts[0].Host.CredentialId.ShouldBe(credentialId, "an unrelated edit must not drop the binding"); } + // ---- Remembering a typed password ---- + + [Fact] + public async Task RememberingATypedPassword_BindsItToTheHostSoItIsNotAskedForAgain() + { + var vault = await ReadyToConnectAsync(); + + vault.RemembersConnectPassword.ShouldBeFalse("storing a password stays a decision"); + + vault.ConnectPassword = "s3cret"; + vault.RemembersConnectPassword = true; + + await ConnectAndRememberAsync(vault); + + // An ordinary keychain credential, named after the host, and carrying no username of its own — the + // connection that just succeeded used the host's, and pinning a copy of it here would stop following + // the host. + var stored = vault.Credentials.ShouldHaveSingleItem(); + stored.Label.ShouldBe("prod-db"); + stored.Credential.Password.ShouldBe("s3cret"); + stored.Credential.Username.ShouldBeNull(); + + var host = vault.Hosts.ShouldHaveSingleItem(); + host.Host.CredentialId.ShouldBe(stored.EntityId); + host.Authentication.ShouldBe("credential"); + + // The box has nothing left to hold and nothing left to ask, and the tick does not carry over to + // whatever host is selected next. + vault.ConnectPassword.ShouldBeEmpty(); + vault.RemembersConnectPassword.ShouldBeFalse(); + vault.SelectedHostAsksForAPassword.ShouldBeFalse(); + } + + [Fact] + public async Task ARememberedPassword_SurvivesTheServerAndIsSentOnTheNextConnection() + { + // The whole point of storing it in the vault rather than on this machine: it is a property of the + // host that reaches the other machines, not a box this one happens to remember filling in. + var vault = await ReadyToConnectAsync(); + + // One renderer for both connections. The page's token is spent on the first attach, so a second + // FakeRenderer is answered with a 409 — which is the real renderer's behaviour too, and the reason + // nothing else in this suite connects twice. + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + + vault.ConnectPassword = "s3cret"; + vault.RemembersConnectPassword = true; + + await vault.ConnectCommand.ExecuteAsync(null); + + await vault.SyncCommand.ExecuteAsync(null); + await vault.LoadAsync(Token); + + vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret"); + + vault.SelectedHost = vault.Hosts[0]; + vault.ConnectPassword.ShouldBeEmpty("nothing should need typing now"); + + await vault.ConnectCommand.ExecuteAsync(null); + + ssh.Requests.Count.ShouldBe(2); + ssh.Requests[1].Credential.ShouldBeOfType().Password.ShouldBe("s3cret"); + } + + [Fact] + public async Task ARefusedConnection_RemembersNothing() + { + // The failure this feature could most easily cause: a typo bound to the host, which then stops asking + // and cannot be connected to until somebody works out that the keychain is where the wrong password + // now lives. Only a handshake the remote accepted is worth keeping. + var vault = await ReadyToConnectAsync(); + + ssh.Failure = new InvalidOperationException("authentication failed"); + + vault.ConnectPassword = "wrong"; + vault.RemembersConnectPassword = true; + + await vault.ConnectCommand.ExecuteAsync(null); + + vault.Credentials.ShouldBeEmpty(); + vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull(); + vault.SelectedHostAsksForAPassword.ShouldBeTrue(); + } + + [Fact] + public async Task ConnectingWithoutTheTick_StoresNothing() + { + // The other half of the decision, and the reason the typed box still exists: a one-off password on a + // machine somebody will never open again must not end up synchronised to every device they own. + var vault = await ReadyToConnectAsync(); + + vault.ConnectPassword = "s3cret"; + + await ConnectWithRendererAsync(vault); + + vault.Credentials.ShouldBeEmpty(); + vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull(); + vault.ConnectPassword.ShouldBe("s3cret", "the box is left as it was typed"); + } + + [Fact] + public async Task RememberingIsIgnoredForAHostThatDoesNotAskForAPassword() + { + // A tick left over from a host that did ask must not manufacture a credential out of a stored one's + // password — which is what reading the dialled secret without checking the binding would do. + var vault = await ReadyToConnectAsync(); + await AddCredentialAsync(vault, "prod deploy", password: "s3cret"); + await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId); + + vault.SelectedHost = vault.Hosts[0]; + vault.RemembersConnectPassword = true; + + await ConnectWithRendererAsync(vault); + + vault.Credentials.ShouldHaveSingleItem("nothing should have been added to the keychain"); + } + /// /// The reason the picker is one control rather than two. HostSecret.TryValidate refuses a host naming /// both a key and a credential, so two pickers would have been able to express the state and would have had @@ -5017,6 +5134,24 @@ public sealed class ShellFlowTests : IAsyncLifetime vault.Status.ShouldContain("Connected", Case.Insensitive); } + /// + /// The same, for a connection that is expected to store its password. + /// + /// + /// Without the status assertion, and that is the whole reason it is separate. Remembering writes two + /// items and then pushes them, exactly as saving a host does, so the pass repaints the line with its own + /// count — leaving "Connected" true of what happened and false of what the line says. What the connection + /// actually did is asserted on the vault, which is where it is durable. + /// + private async Task ConnectAndRememberAsync(VaultViewModel vault) + { + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + + await vault.ConnectCommand.ExecuteAsync(null); + + ssh.Requests.ShouldNotBeEmpty("the password is only kept once a handshake has succeeded"); + } + /// An unlocked vault with one selected host and a renderer attached. private async Task ReadyToConnectAsync() {