diff --git a/src/Renci.SshNet/PrivateKeyFile.cs b/src/Renci.SshNet/PrivateKeyFile.cs index c7b9ccbc9..018d68cbf 100644 --- a/src/Renci.SshNet/PrivateKeyFile.cs +++ b/src/Renci.SshNet/PrivateKeyFile.cs @@ -110,18 +110,23 @@ namespace Renci.SshNet /// public partial class PrivateKeyFile : IPrivateKeySource, IDisposable { - private const string PrivateKeyPattern = @"^-+ *BEGIN (?\w+( \w+)*) *-+\r?\n((Proc-Type: 4,ENCRYPTED\r?\nDEK-Info: (?[A-Z0-9-]+),(?[a-fA-F0-9]+)\r?\n\r?\n)|(Comment: ""?[^\r\n]*""?\r?\n))?(?([a-zA-Z0-9/+=]{1,80}\r?\n)+)(\r?\n)?-+ *END \k *-+"; + private const string PrivateKeyPattern = @"^-+ *BEGIN (?\w+( \w+)*) *-+[\r\n\t ]+((Proc-Type: 4,ENCRYPTED[\r\n\t ]+DEK-Info: (?[A-Z0-9-]+),(?[a-fA-F0-9]+)[\r\n\t ]+[\r\n\t ]+)|(Comment: ""?[^\r\n]*""?[\r\n\t ]+))?(?[a-zA-Z0-9/+=\r\n\t ]+)([\r\n\t ]+)?-+ *END \k *-+"; + private const string InlinePrivateKeyPattern = @"^(?-+ *BEGIN (?\w+( \w+)*) *-+)(?[a-zA-Z0-9/+=]+)(?-+ *END \k *-+)$"; private const string PuTTYPrivateKeyPattern = @"^(?PuTTY-User-Key-File)-(?\d+): (?[\w-]+)\r?\nEncryption: (?[\w-]+)\r?\nComment: (?.*?)\r?\nPublic-Lines: \d+\r?\n(?(([a-zA-Z0-9/+=]{1,64})\r?\n)+)(Key-Derivation: (?\w+)\r?\nArgon2-Memory: (?\d+)\r?\nArgon2-Passes: (?\d+)\r?\nArgon2-Parallelism: (?\d+)\r?\nArgon2-Salt: (?[a-fA-F0-9]+)\r?\n)?Private-Lines: \d+\r?\n(?(([a-zA-Z0-9/+=]{1,64})\r?\n)+)+Private-MAC: (?[a-fA-F0-9]+)"; private const string CertificatePattern = @"(?[-\w]+@openssh\.com)\s(?[a-zA-Z0-9\/+=]*)(\s+(?.*))?"; #if NET private static readonly Regex PrivateKeyRegex = GetPrivateKeyRegex(); + private static readonly Regex InlinePrivateKeyRegex = GetInlinePrivateKeyRegex(); private static readonly Regex PuTTYPrivateKeyRegex = GetPrivateKeyPuTTYRegex(); private static readonly Regex CertificateRegex = GetCertificateRegex(); [GeneratedRegex(PrivateKeyPattern, RegexOptions.Multiline | RegexOptions.ExplicitCapture)] private static partial Regex GetPrivateKeyRegex(); + [GeneratedRegex(InlinePrivateKeyPattern, RegexOptions.ExplicitCapture)] + private static partial Regex GetInlinePrivateKeyRegex(); + [GeneratedRegex(PuTTYPrivateKeyPattern, RegexOptions.Multiline | RegexOptions.ExplicitCapture)] private static partial Regex GetPrivateKeyPuTTYRegex(); @@ -129,6 +134,7 @@ public partial class PrivateKeyFile : IPrivateKeySource, IDisposable private static partial Regex GetCertificateRegex(); #else private static readonly Regex PrivateKeyRegex = new Regex(PrivateKeyPattern, RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture); + private static readonly Regex InlinePrivateKeyRegex = new Regex(InlinePrivateKeyPattern, RegexOptions.Compiled | RegexOptions.ExplicitCapture); private static readonly Regex PuTTYPrivateKeyRegex = new Regex(PuTTYPrivateKeyPattern, RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture); private static readonly Regex CertificateRegex = new Regex(CertificatePattern, RegexOptions.Compiled | RegexOptions.ExplicitCapture); #endif @@ -292,7 +298,7 @@ private void Open(Stream privateKey, string? passPhrase) using (var sr = new StreamReader(privateKey)) { - var text = sr.ReadToEnd(); + var text = NormalizeInlinePrivateKey(sr.ReadToEnd()); if (text.StartsWith("PuTTY-User-Key-File", StringComparison.Ordinal)) { privateKeyMatch = PuTTYPrivateKeyRegex.Match(text); @@ -379,6 +385,27 @@ private void Open(Stream privateKey, string? passPhrase) } } + private static string NormalizeInlinePrivateKey(string text) + { + if (text.IndexOfAny(['\r', '\n']) >= 0) + { + return text; + } + + var privateKeyMatch = InlinePrivateKeyRegex.Match(text.Trim()); + if (!privateKeyMatch.Success) + { + return text; + } + + return string.Concat( + privateKeyMatch.Groups["begin"].Value, + "\n", + privateKeyMatch.Groups["data"].Value, + "\n", + privateKeyMatch.Groups["end"].Value); + } + /// /// Opens the specified certificate. /// diff --git a/test/Renci.SshNet.Tests/Classes/PrivateKeyFileTest.cs b/test/Renci.SshNet.Tests/Classes/PrivateKeyFileTest.cs index f18cef76e..4f0b5a3e4 100644 --- a/test/Renci.SshNet.Tests/Classes/PrivateKeyFileTest.cs +++ b/test/Renci.SshNet.Tests/Classes/PrivateKeyFileTest.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -609,6 +610,40 @@ public void PuTTYv3_InvalidMac_ThrowsSshException() } } + [TestMethod] + [DataRow("Key.RSA.PKCS8.txt", null, typeof(RsaKey))] + [DataRow("Key.OPENSSH.RSA.txt", null, typeof(RsaKey))] + [DataRow("Key.RSA.txt", null, typeof(RsaKey))] + [DataRow("Key.ECDSA.txt", null, typeof(EcdsaKey))] + [DataRow("Key.OPENSSH.ED25519.txt", null, typeof(ED25519Key))] + public void Test_PrivateKey_InlinePem_LineEndingsRemoved(string name, string passPhrase, Type expectedKeyType) + { + // Simulate CI/CD environment variable injection (e.g. Azure DevOps) where + // the PEM line endings are removed, producing an inline single-line key. + string original; + using (var stream = GetData(name)) + using (var reader = new StreamReader(stream)) + { + original = reader.ReadToEnd(); + } + + // Remove all line endings to produce the inline format, + // matching what CI/CD systems (e.g. Azure DevOps) produce when injecting secrets. + var inlinePem = Regex.Replace(original, @"\r\n?|\n", string.Empty).Trim(); + + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(inlinePem))) + { + var pkFile = new PrivateKeyFile(stream, passPhrase); + + Assert.IsInstanceOfType(pkFile.Key, expectedKeyType); + + if (expectedKeyType == typeof(RsaKey)) + { + TestRsaKeyFile(pkFile); + } + } + } + private void SaveStreamToFile(Stream stream, string fileName) { var buffer = new byte[4000];