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); }