Public Access
Offer to bring the keys an ssh_config points at
An import that recorded a key path and left every host asking for a password was an import whose result did not connect. The answer to that was a manual paste per key, which is the sort of thing people do once and then stop importing. So there is a tick, and it starts off. With it off nothing changes: an IdentityFile becomes a note and the host asks for a password. With it on, IMPORT reads each host's first IdentityFile out of ~/.ssh, stores it in the vault encrypted like any other key, and binds the host to it. Three things about how it is drawn are load-bearing rather than tidy. It is a default nobody arrives at by accident. The sentence beside it names the directory rather than saying "your keys", because that is what somebody is agreeing to. And nothing is read during SCAN — tick it, read what it says, untick it, and no private key has been opened. This is the only place the application opens key material out of a directory the user did not point at file by file, and the whole of what makes that acceptable is that it took a deliberate press. One vault key per file, however many entries named it: an ssh_config pointing twelve hosts at one id_ed25519 is the ordinary shape, and twelve copies would be twelve things to rotate and eleven to forget. A file whose material is already in the keychain is bound to rather than stored again, which is what makes running the import twice harmless. What cannot be read off a disk is a passphrase, so a protected key arrives without one — and the report under the button names those files rather than leaving a host to fail at connect time with a message about a malformed key. Telling them apart means decoding for OpenSSH's own container, whose cipher name is the first field inside the base64 rather than anything in the armour, and that is the format ssh-keygen has written by default for years. The 88 base64 characters it decodes need 66 bytes, not 64: with the smaller span every protected key came back unprotected, which the tests now pin. A path that is not on this machine leaves its host imported and unbound, exactly as it would have been with the tick off, and is named in the same report. A config carried from another machine is the ordinary case, not an error.
This commit is contained in:
@@ -5782,14 +5782,15 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores several hosts at once, the way one save does.
|
||||
/// Stores several hosts at once, the way one save does, with the keys they named where those are coming
|
||||
/// too.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
|
||||
/// through the same repository, the same outbox and the same automatic push as saving one — the import
|
||||
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host to be
|
||||
/// written and no second place for the sync wiring to be forgotten.
|
||||
/// through the same repositories, the same outbox and the same automatic push as saving one — the import
|
||||
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host or a key to
|
||||
/// be written and no second place for the sync wiring to be forgotten.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
|
||||
@@ -5800,47 +5801,142 @@ internal sealed partial class VaultViewModel(
|
||||
/// A host that fails validation is skipped and counted rather than aborting the batch. Twenty-nine good
|
||||
/// hosts thrown away because the thirtieth had no hostname is not what anybody wants from an import.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>One vault key per file, however many hosts named it.</b> An <c>ssh_config</c> pointing twelve
|
||||
/// entries at <c>~/.ssh/id_ed25519</c> is the ordinary shape, and twelve copies of one private key would
|
||||
/// be twelve things to rotate and eleven to forget — which is the argument <c>HostSecret.SshKeyId</c>
|
||||
/// already makes for referencing a key rather than embedding it. The path is what identifies a file here,
|
||||
/// because it is the only thing the config gave that two entries can be compared on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A key whose material is already in the keychain is bound to rather than stored again</b>, which is
|
||||
/// what makes running the import twice harmless. Compared on the armour verbatim, as the codec stores it:
|
||||
/// a re-import of the same file is byte-identical, and anything that is not is a different key whatever
|
||||
/// it is called.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<int> ImportHostsAsync(
|
||||
IReadOnlyList<HostSecret> hosts,
|
||||
internal async Task<ImportOutcome> ImportHostsAsync(
|
||||
IReadOnlyList<ImportedHostRequest> requests,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hosts);
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
|
||||
var imported = 0;
|
||||
var hosts = 0;
|
||||
var keys = 0;
|
||||
var refused = 0;
|
||||
|
||||
await RunAsync(
|
||||
hosts.Count == 1 ? "Importing 1 host…" : $"Importing {hosts.Count} hosts…",
|
||||
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
|
||||
async () =>
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
||||
// front: the second host naming a file has to land on the id the first one produced, and
|
||||
// nothing is reloaded until the batch finishes.
|
||||
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
if (!host.TryValidate(out _))
|
||||
if (!request.Host.TryValidate(out _))
|
||||
{
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (created)
|
||||
{
|
||||
keys++;
|
||||
}
|
||||
|
||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||
|
||||
await session.Hosts
|
||||
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
imported++;
|
||||
hosts++;
|
||||
}
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped.";
|
||||
|
||||
Status = connection() is null
|
||||
? $"Imported {imported} host(s). They will sync when you are online.{refusals}"
|
||||
: $"Imported {imported} host(s).{refusals}";
|
||||
Status = DescribeImport(hosts, keys, refused);
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return imported;
|
||||
return new ImportOutcome(hosts, keys);
|
||||
}
|
||||
|
||||
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
|
||||
/// <returns>
|
||||
/// The key to bind to, and whether storing it was what put it there. The second half is what the count
|
||||
/// reported afterwards is about: binding to a key the keychain already held reads nothing off a disk and
|
||||
/// must not be reported as having.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The keychain is searched as well as this batch's own map, and both are needed. The map answers within
|
||||
/// one run, where nothing has been reloaded yet; the keychain answers for a run an hour ago, which is
|
||||
/// what makes importing the same config twice leave one key rather than two.
|
||||
/// </remarks>
|
||||
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
|
||||
ImportedHostRequest request,
|
||||
Dictionary<string, Guid> bound,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Key is not { } key || request.KeyPath is not { } path)
|
||||
{
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
if (bound.TryGetValue(path, out var already))
|
||||
{
|
||||
return (already, false);
|
||||
}
|
||||
|
||||
if (Keys.FirstOrDefault(row => string.Equals(
|
||||
row.Key.PrivateKeyPem, key.PrivateKeyPem, StringComparison.Ordinal)) is { } existing)
|
||||
{
|
||||
bound[path] = existing.EntityId;
|
||||
|
||||
return (existing.EntityId, false);
|
||||
}
|
||||
|
||||
if (!key.TryValidate(out _))
|
||||
{
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
var id = await session.SshKeys
|
||||
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
bound[path] = id;
|
||||
|
||||
return (id, true);
|
||||
}
|
||||
|
||||
/// <summary>What the import did, in one sentence.</summary>
|
||||
/// <remarks>
|
||||
/// The keys are named separately from the hosts and only when there are any, because importing key
|
||||
/// material is the half of this somebody agreed to rather than the half they asked for — folded into
|
||||
/// "imported 12 hosts" it would be the one number worth saying out loud, said quietly.
|
||||
/// </remarks>
|
||||
private string DescribeImport(int hosts, int keys, int refused)
|
||||
{
|
||||
var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped.";
|
||||
|
||||
var material = keys switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => " 1 private key came with them.",
|
||||
_ => $" {keys} private keys came with them.",
|
||||
};
|
||||
|
||||
return connection() is null
|
||||
? $"Imported {hosts} host(s). They will sync when you are online.{material}{refusals}"
|
||||
: $"Imported {hosts} host(s).{material}{refusals}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user