using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Import;
///
/// A private key file an ssh_config named, as the import screen has to describe it.
///
///
/// 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.
///
/// The file this is about, resolved. What a message names, so it is never null.
/// The armour, verbatim, or null when it could not be read.
///
/// The .pub beside it, if there is one. Optional for the reason SshKeySecret.PublicKey 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.
///
///
/// 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.
///
/// Why there is no key, or null when there is one.
public sealed record SshIdentityFile(
string Path,
string? PrivateKeyPem,
string? PublicKey,
bool IsEncrypted,
string? Failure)
{
/// Whether there is key material to store.
[MemberNotNullWhen(true, nameof(PrivateKeyPem))]
public bool WasRead => PrivateKeyPem is { Length: > 0 };
}
///
/// Reading the private keys an ssh_config points at, and telling encrypted ones apart.
///
///
///
/// Nothing in here runs unless somebody ticked a box. 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.
///
///
/// It does not parse key material. SshKeySecret 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
/// into a key for is whether it is encrypted, because that decides what the screen has to say next.
///
///
public static class SshIdentityFiles
{
/// The OpenSSH v1 container's magic, which is what an armoured OPENSSH PRIVATE KEY holds.
private static readonly byte[] OpenSshMagic = "openssh-key-v1\0"u8.ToArray();
///
/// Reads the key at a path an ssh_config gave.
///
///
/// The IdentityFile value. Already tilde-expanded by ; a relative
/// one is resolved against , which is OpenSSH's own rule.
///
/// Where ~/.ssh is for this run.
///
///
/// The .pub 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 present one is
/// worth having, and the mistake it heads off is the one SshKeySecret.TryValidate exists to
/// catch — somebody later pasting the wrong one of two files whose names differ by four characters.
///
///
/// 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.
///
///
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);
}
}
///
/// Whether the armour is passphrase-protected.
///
///
///
/// Three formats and three tells. PKCS#8 says so in the header outright. Classic PEM carries a
/// Proc-Type: 4,ENCRYPTED line above the base64. OpenSSH's own container says nothing in the
/// armour at all — the cipher name is the first field inside the base64 — which is why this
/// decodes rather than reading headers, and it is the format ssh-keygen has written by default
/// for years, so it is the case that actually turns up.
///
///
/// 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.
///
///
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);
}
///
/// Reads the cipher name out of an OpenSSH v1 private key container.
///
///
/// The container is openssh-key-v1\0 followed by SSH string fields, each a big-endian length and
/// then that many bytes. The first of them is the cipher, and none 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.
///
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 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;
}
/// Decodes the first few base64 characters of an armoured body.
///
/// 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.
///
private static bool TryDecodeHead(ReadOnlySpan body, Span destination, out int written)
{
Span 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);
}
///
/// Absent is not a failure, so this answers null rather than a reason. Read with the same swallow the
/// private half uses: a .pub that cannot be read costs the public line and nothing else.
///
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);
}