Public Access
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.
280 lines
11 KiB
C#
280 lines
11 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
|
|
namespace DodoSSH.Client.Import;
|
|
|
|
/// <summary>
|
|
/// A private key file an <c>ssh_config</c> named, as the import screen has to describe it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A record rather than a thrown exception per failure, because every outcome here is a row on a screen
|
|
/// rather than an error: a path that does not exist is the ordinary shape of a config carried between
|
|
/// machines, and a config with forty entries will have several. The import goes ahead without them and says
|
|
/// which.
|
|
/// </remarks>
|
|
/// <param name="Path">The file this is about, resolved. What a message names, so it is never null.</param>
|
|
/// <param name="PrivateKeyPem">The armour, verbatim, or null when it could not be read.</param>
|
|
/// <param name="PublicKey">
|
|
/// The <c>.pub</c> beside it, if there is one. Optional for the reason <c>SshKeySecret.PublicKey</c> is
|
|
/// optional — it cannot be derived without parsing every format this stores verbatim — and worth taking
|
|
/// while it is right there, because installing a key on a host needs exactly that line.
|
|
/// </param>
|
|
/// <param name="IsEncrypted">
|
|
/// Whether the file is passphrase-protected. Not a failure: the key is worth importing either way, and the
|
|
/// passphrase is the one thing on disk that cannot be read off it. The import says so and the keychain's
|
|
/// editor is where it gets added.
|
|
/// </param>
|
|
/// <param name="Failure">Why there is no key, or null when there is one.</param>
|
|
public sealed record SshIdentityFile(
|
|
string Path,
|
|
string? PrivateKeyPem,
|
|
string? PublicKey,
|
|
bool IsEncrypted,
|
|
string? Failure)
|
|
{
|
|
/// <summary>Whether there is key material to store.</summary>
|
|
[MemberNotNullWhen(true, nameof(PrivateKeyPem))]
|
|
public bool WasRead => PrivateKeyPem is { Length: > 0 };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reading the private keys an <c>ssh_config</c> points at, and telling encrypted ones apart.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Nothing in here runs unless somebody ticked a box.</b> Pulling a private key out of a home directory
|
|
/// is the act this product exists to make deliberate, so it is opt-in on the import screen, off by default,
|
|
/// and said out loud beside the tick. What this type is responsible for is doing it honestly once asked:
|
|
/// reading exactly the files the config named, saying which it could not, and never guessing at a path the
|
|
/// config did not give.
|
|
/// </para>
|
|
/// <para>
|
|
/// It does not parse key material. <c>SshKeySecret</c> stores whatever armour it is handed and declines to
|
|
/// re-encode it, for reasons written there, and this keeps to the same rule — the one thing it looks
|
|
/// <em>into</em> a key for is whether it is encrypted, because that decides what the screen has to say next.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class SshIdentityFiles
|
|
{
|
|
/// <summary>The OpenSSH v1 container's magic, which is what an armoured OPENSSH PRIVATE KEY holds.</summary>
|
|
private static readonly byte[] OpenSshMagic = "openssh-key-v1\0"u8.ToArray();
|
|
|
|
/// <summary>
|
|
/// Reads the key at a path an <c>ssh_config</c> gave.
|
|
/// </summary>
|
|
/// <param name="path">
|
|
/// The <c>IdentityFile</c> value. Already tilde-expanded by <see cref="SshConfigResolver"/>; a relative
|
|
/// one is resolved against <paramref name="sshDirectory"/>, which is OpenSSH's own rule.
|
|
/// </param>
|
|
/// <param name="sshDirectory">Where <c>~/.ssh</c> is for this run.</param>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The <c>.pub</c> is read as a side dish and never as a requirement: a missing one costs the public
|
|
/// half and nothing else, so it is silently absent rather than a failure. A <em>present</em> one is
|
|
/// worth having, and the mistake it heads off is the one <c>SshKeySecret.TryValidate</c> exists to
|
|
/// catch — somebody later pasting the wrong one of two files whose names differ by four characters.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every failure is caught and returned rather than thrown. A config that names keys on a machine it
|
|
/// was copied from is the ordinary case, and one unreadable path must not stop the other thirty-nine
|
|
/// entries being imported.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static SshIdentityFile Read(string path, string sshDirectory)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
|
ArgumentNullException.ThrowIfNull(sshDirectory);
|
|
|
|
var resolved = System.IO.Path.IsPathRooted(path)
|
|
? path
|
|
: System.IO.Path.Combine(sshDirectory, path);
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(resolved))
|
|
{
|
|
return Refused(resolved, "there is no such file on this machine");
|
|
}
|
|
|
|
var material = File.ReadAllText(resolved);
|
|
|
|
if (material.TrimStart().StartsWith("ssh-", StringComparison.Ordinal)
|
|
|| material.TrimStart().StartsWith("ecdsa-", StringComparison.Ordinal))
|
|
{
|
|
// The .pub, named as the IdentityFile. ssh itself tolerates this — it looks for the private
|
|
// half beside it — and importing the public one would produce a keychain entry that looks
|
|
// fine and fails at connect time saying nothing about which file was chosen.
|
|
return Refused(resolved, "that is the public half; the private key is the file without .pub");
|
|
}
|
|
|
|
if (!material.TrimStart().StartsWith("-----BEGIN", StringComparison.Ordinal))
|
|
{
|
|
return Refused(resolved, "it does not look like a private key");
|
|
}
|
|
|
|
return new SshIdentityFile(
|
|
resolved, material, ReadPublicHalf(resolved), IsEncrypted(material), Failure: null);
|
|
}
|
|
catch (IOException failure)
|
|
{
|
|
return Refused(resolved, failure.Message);
|
|
}
|
|
catch (UnauthorizedAccessException failure)
|
|
{
|
|
return Refused(resolved, failure.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether the armour is passphrase-protected.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Three formats and three tells. PKCS#8 says so in the header outright. Classic PEM carries a
|
|
/// <c>Proc-Type: 4,ENCRYPTED</c> line above the base64. OpenSSH's own container says nothing in the
|
|
/// armour at all — the cipher name is the first field <em>inside</em> the base64 — which is why this
|
|
/// decodes rather than reading headers, and it is the format <c>ssh-keygen</c> has written by default
|
|
/// for years, so it is the case that actually turns up.
|
|
/// </para>
|
|
/// <para>
|
|
/// Wrong in the safe direction when it cannot tell: anything it fails to decode is reported as
|
|
/// unencrypted, which means the key is imported with no warning and fails at connect time with SSH.NET's
|
|
/// own message. The opposite default would put a warning on every key, and a warning everybody sees is
|
|
/// one nobody reads.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static bool IsEncrypted(string material)
|
|
{
|
|
if (material.Contains("BEGIN ENCRYPTED PRIVATE KEY", StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (material.Contains("Proc-Type:", StringComparison.Ordinal)
|
|
&& material.Contains("ENCRYPTED", StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return TryReadOpenSshCipher(material, out var cipher)
|
|
&& !string.Equals(cipher, "none", StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the cipher name out of an OpenSSH v1 private key container.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The container is <c>openssh-key-v1\0</c> followed by SSH string fields, each a big-endian length and
|
|
/// then that many bytes. The first of them is the cipher, and <c>none</c> is what an unprotected key
|
|
/// carries. Only that first field is read: everything after it is key material, and this type does not
|
|
/// look at key material.
|
|
/// </remarks>
|
|
private static bool TryReadOpenSshCipher(string material, [NotNullWhen(true)] out string? cipher)
|
|
{
|
|
cipher = null;
|
|
|
|
const string header = "-----BEGIN OPENSSH PRIVATE KEY-----";
|
|
const string footer = "-----END OPENSSH PRIVATE KEY-----";
|
|
|
|
var start = material.IndexOf(header, StringComparison.Ordinal);
|
|
var end = material.IndexOf(footer, StringComparison.Ordinal);
|
|
|
|
if (start < 0 || end <= start)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var body = material[(start + header.Length)..end];
|
|
|
|
// 88 base64 characters decode to 66 bytes, so the destination is 72 rather than 64 — a span too
|
|
// small makes Convert.TryFromBase64Chars answer false, which this would read as "not an OpenSSH
|
|
// container" and report as unencrypted. Measured: every protected key came back unprotected.
|
|
Span<byte> decoded = stackalloc byte[72];
|
|
|
|
// Only the head of the container is needed and the whole of it may be megabytes, so just enough of
|
|
// the base64 is decoded for the magic and the first field.
|
|
var bytes = TryDecodeHead(body, decoded, out var written) ? decoded[..written] : default;
|
|
|
|
if (bytes.Length < OpenSshMagic.Length + 4)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!bytes[..OpenSshMagic.Length].SequenceEqual(OpenSshMagic))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var rest = bytes[OpenSshMagic.Length..];
|
|
var length = (rest[0] << 24) | (rest[1] << 16) | (rest[2] << 8) | rest[3];
|
|
|
|
if (length is < 1 or > 64 || rest.Length < 4 + length)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
cipher = System.Text.Encoding.ASCII.GetString(rest.Slice(4, length));
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Decodes the first few base64 characters of an armoured body.</summary>
|
|
/// <remarks>
|
|
/// The armour is wrapped at 70 characters and the destination is 64 bytes, so this takes the first
|
|
/// non-whitespace 88 characters — a multiple of four, decoding to 66 — and stops. Trailing padding never
|
|
/// enters into it because the slice never reaches the end of a real key.
|
|
/// </remarks>
|
|
private static bool TryDecodeHead(ReadOnlySpan<char> body, Span<byte> destination, out int written)
|
|
{
|
|
Span<char> head = stackalloc char[88];
|
|
var taken = 0;
|
|
|
|
foreach (var character in body)
|
|
{
|
|
if (char.IsWhiteSpace(character))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
head[taken++] = character;
|
|
|
|
if (taken == head.Length)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (taken < head.Length)
|
|
{
|
|
written = 0;
|
|
return false;
|
|
}
|
|
|
|
return Convert.TryFromBase64Chars(head, destination, out written);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Absent is not a failure, so this answers null rather than a reason. Read with the same swallow the
|
|
/// private half uses: a <c>.pub</c> that cannot be read costs the public line and nothing else.
|
|
/// </remarks>
|
|
private static string? ReadPublicHalf(string privatePath)
|
|
{
|
|
try
|
|
{
|
|
var pub = privatePath + ".pub";
|
|
|
|
return File.Exists(pub) ? File.ReadAllText(pub).Trim() : null;
|
|
}
|
|
catch (IOException)
|
|
{
|
|
return null;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static SshIdentityFile Refused(string path, string reason) =>
|
|
new(path, PrivateKeyPem: null, PublicKey: null, IsEncrypted: false, Failure: reason);
|
|
}
|