Keep the password that just worked, so the host stops asking for it

The vault has held credentials and host bindings since they landed, and the
only route from a typed password into one ran through two screens: add a
password under Keychain, open the host, bind it. The password box's own tooltip
instructed people to do exactly that by hand — which means typing the secret a
second time, into a screen that has no idea which host it is for, while the
screen that does have the password is the one being left behind. A tick beside
the box does it in one step.

What it produces is an ordinary CredentialSecret, named after the host, bound
through CredentialId with AsksForPassword cleared beside it. That is the whole
reason nothing else here had to change: it syncs, it merges, it appears in the
keychain, it can be renamed and deleted, and it can be bound to the other
nineteen machines that share the account. A password field on HostSecret would
have been a payload schema bump, a fourth place a secret lives, and a copy per
host to rotate and forget.

It waits for a handshake the remote accepted, and that is not caution for its
own sake. Binding on the keystroke would store whatever was in the box —
including the typo about to be refused — and the host would then stop asking,
leaving a machine nobody can connect to until they work out that the keychain
is where the wrong password now lives. For the same reason the password is read
from the credential as dialled rather than from the box, which stays typeable
throughout a handshake.

Off by default, because the typed box exists precisely for the passwords that
should not be in a synchronised vault — a one-off on a machine somebody will
never open again. The credential is written into the host's own vault rather
than the active one: in the personal vault, bound to a team's host, it would be
a binding every other member can see and none of them can resolve.

One thing is given up knowingly. The confirmation naming where the password
went is replaced a moment later by the auto-sync's own count, exactly as
SaveHostAsync's "Saved 'x'" is; this follows that rather than becoming the one
write that suppresses a sync line. The feedback that lasts is the row answering
"credential" and the box disappearing — which is also the only way to store a
password on the phone at all, since that head lists credentials but has never
had an editor to create one in.
This commit is contained in:
2026-08-03 14:40:25 +02:00
parent 416f233657
commit b86ff6158b
4 changed files with 278 additions and 7 deletions
@@ -333,7 +333,13 @@ internal sealed partial class HostRowViewModel(
/// <summary>What a host can authenticate with.</summary>
internal enum AuthenticationKind
{
/// <summary>Typed at the moment of connecting, and never stored.</summary>
/// <summary>Typed at the moment of connecting.</summary>
/// <remarks>
/// 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 <see cref="Credential"/>, so a stored password is always
/// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See
/// <see cref="VaultViewModel.RemembersConnectPassword"/>.
/// </remarks>
Typed,
/// <summary>An SSH key in this vault.</summary>
@@ -1984,14 +1990,36 @@ internal sealed partial class VaultViewModel(
// ---- Connecting ----
/// <remarks>
/// 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 <see cref="RemembersConnectPassword"/> 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.
/// </remarks>
[ObservableProperty]
private string connectPassword = string.Empty;
/// <summary>
/// Whether a password typed here should be kept, so this host stops asking for it.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Off by default, and it stays a decision.</b> 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
/// <see cref="RememberTypedPasswordAsync"/> — because a password that has just been refused is precisely
/// the one not worth keeping.
/// </para>
/// </remarks>
[ObservableProperty]
private bool remembersConnectPassword;
/// <summary>
/// Whether the selected host will want something typed into the password box.
/// </summary>
@@ -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);
}
/// <summary>
/// Turns the password that just worked into a keychain credential bound to this host.
/// </summary>
/// <remarks>
/// <para>
/// <b>Only after a handshake the remote accepted.</b> 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.
/// </para>
/// <para>
/// <b>A credential rather than a field on the host, which is why nothing else here had to change.</b>
/// 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 <see cref="HostSecret"/>
/// on why the binding is an id and not a copy.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Every failure is reported and swallowed. The caller's <c>catch</c> 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.
/// </para>
/// </remarks>
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);
}
/// <summary>The address as actually dialled.</summary>