mirror of
https://github.com/sshnet/SSH.NET.git
synced 2026-09-10 01:05:42 +00:00
Support inline PEM keys where newlines were replaced by spaces (e.g. CI/CD injection)
This commit is contained in:
committed by
GitHub
parent
5b8382de26
commit
f6dbb818d4
@@ -7,6 +7,7 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using Renci.SshNet.Common;
|
||||
@@ -114,10 +115,20 @@ namespace Renci.SshNet
|
||||
private const string PuTTYPrivateKeyPattern = @"^(?<keyName>PuTTY-User-Key-File)-(?<version>\d+): (?<algorithmName>[\w-]+)\r?\nEncryption: (?<encryptionType>[\w-]+)\r?\nComment: (?<comment>.*?)\r?\nPublic-Lines: \d+\r?\n(?<publicKey>(([a-zA-Z0-9/+=]{1,64})\r?\n)+)(Key-Derivation: (?<argon2Type>\w+)\r?\nArgon2-Memory: (?<argon2Memory>\d+)\r?\nArgon2-Passes: (?<argon2Passes>\d+)\r?\nArgon2-Parallelism: (?<argon2Parallelism>\d+)\r?\nArgon2-Salt: (?<argon2Salt>[a-fA-F0-9]+)\r?\n)?Private-Lines: \d+\r?\n(?<data>(([a-zA-Z0-9/+=]{1,64})\r?\n)+)+Private-MAC: (?<mac>[a-fA-F0-9]+)";
|
||||
private const string CertificatePattern = @"(?<type>[-\w]+@openssh\.com)\s(?<data>[a-zA-Z0-9\/+=]*)(\s+(?<comment>.*))?";
|
||||
|
||||
/// <summary>
|
||||
/// Matches an inline (single-line) PEM key where newlines were replaced by spaces,
|
||||
/// for example: <c>-----BEGIN PRIVATE KEY----- <base64data> -----END PRIVATE KEY-----</c>.
|
||||
/// This happens when SSH private keys are injected via CI/CD environment variables
|
||||
/// (e.g., Azure DevOps) that do not preserve newlines.
|
||||
/// Groups: header = BEGIN header, data = base64 payload (may contain spaces), footer = END footer.
|
||||
/// </summary>
|
||||
private const string InlinePemPattern = @"(?<header>-{5}BEGIN [^-]+-{5})\s+(?<data>[a-zA-Z0-9+/=]+(?:\s+[a-zA-Z0-9+/=]+)*)\s*(?<footer>-{5}END [^-]+-{5})";
|
||||
|
||||
#if NET
|
||||
private static readonly Regex PrivateKeyRegex = GetPrivateKeyRegex();
|
||||
private static readonly Regex PuTTYPrivateKeyRegex = GetPrivateKeyPuTTYRegex();
|
||||
private static readonly Regex CertificateRegex = GetCertificateRegex();
|
||||
private static readonly Regex InlinePemRegex = GetInlinePemRegex();
|
||||
|
||||
[GeneratedRegex(PrivateKeyPattern, RegexOptions.Multiline | RegexOptions.ExplicitCapture)]
|
||||
private static partial Regex GetPrivateKeyRegex();
|
||||
@@ -127,10 +138,14 @@ namespace Renci.SshNet
|
||||
|
||||
[GeneratedRegex(CertificatePattern, RegexOptions.ExplicitCapture)]
|
||||
private static partial Regex GetCertificateRegex();
|
||||
|
||||
[GeneratedRegex(InlinePemPattern, RegexOptions.ExplicitCapture)]
|
||||
private static partial Regex GetInlinePemRegex();
|
||||
#else
|
||||
private static readonly Regex PrivateKeyRegex = new Regex(PrivateKeyPattern, RegexOptions.Compiled | RegexOptions.Multiline | 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);
|
||||
private static readonly Regex InlinePemRegex = new Regex(InlinePemPattern, RegexOptions.Compiled | RegexOptions.ExplicitCapture);
|
||||
#endif
|
||||
|
||||
private readonly List<HostAlgorithm> _hostAlgorithms = new List<HostAlgorithm>();
|
||||
@@ -278,6 +293,58 @@ namespace Renci.SshNet
|
||||
Debug.Assert(HostKeyAlgorithms.Count > 0, $"{nameof(HostKeyAlgorithms)} is not set.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes an inline PEM key where line breaks were replaced by spaces.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Some CI/CD systems (e.g. Azure DevOps) replace newlines with spaces when injecting
|
||||
/// secrets as environment variables. This method detects that pattern and reformats
|
||||
/// the key into standard multi-line PEM so it can be parsed by the normal regex.
|
||||
/// </remarks>
|
||||
/// <param name="text">The raw text to normalize.</param>
|
||||
/// <returns>
|
||||
/// A new string with proper PEM line endings when the text matched the inline pattern;
|
||||
/// otherwise the original <paramref name="text"/> instance (reference-equal).
|
||||
/// </returns>
|
||||
private static string NormalizePemText(string text)
|
||||
{
|
||||
var inlineMatch = InlinePemRegex.Match(text);
|
||||
if (!inlineMatch.Success)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
var header = inlineMatch.Groups["header"].Value;
|
||||
var footer = inlineMatch.Groups["footer"].Value;
|
||||
|
||||
// Strip whitespace from the base64 payload.
|
||||
// Each space represents a newline that was stripped by the CI/CD injector.
|
||||
var rawData = inlineMatch.Groups["data"].Value;
|
||||
var base64Data = new StringBuilder(rawData.Length);
|
||||
foreach (var c in rawData)
|
||||
{
|
||||
if (!char.IsWhiteSpace(c))
|
||||
{
|
||||
base64Data.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Reformat as standard PEM: header, 64-char base64 lines, footer.
|
||||
const int lineLength = 64;
|
||||
var base64 = base64Data.ToString();
|
||||
var capacity = header.Length + 1 + base64.Length + (base64.Length / lineLength) + 1 + footer.Length + 1;
|
||||
var sb = new StringBuilder(capacity);
|
||||
sb.Append(header).Append('\n');
|
||||
for (var i = 0; i < base64.Length; i += lineLength)
|
||||
{
|
||||
sb.Append(base64, i, Math.Min(lineLength, base64.Length - i));
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
sb.Append(footer).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the specified private key.
|
||||
/// </summary>
|
||||
@@ -300,6 +367,19 @@ namespace Renci.SshNet
|
||||
else
|
||||
{
|
||||
privateKeyMatch = PrivateKeyRegex.Match(text);
|
||||
|
||||
if (!privateKeyMatch.Success)
|
||||
{
|
||||
// Attempt to normalize inline PEM format where newlines were replaced by
|
||||
// spaces (common when injecting SSH keys via CI/CD environment variables,
|
||||
// e.g. Azure DevOps). Only normalize when the standard match fails, to
|
||||
// keep the hot path allocation-free.
|
||||
var normalizedText = NormalizePemText(text);
|
||||
if (!ReferenceEquals(normalizedText, text))
|
||||
{
|
||||
privateKeyMatch = PrivateKeyRegex.Match(normalizedText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -609,6 +609,39 @@ namespace Renci.SshNet.Tests.Classes
|
||||
}
|
||||
}
|
||||
|
||||
[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_SpacesSeparated(string name, string passPhrase, Type expectedKeyType)
|
||||
{
|
||||
// Simulate CI/CD environment variable injection (e.g. Azure DevOps) where
|
||||
// the PEM newlines are replaced by spaces, producing an inline single-line key.
|
||||
string original;
|
||||
using (var stream = GetData(name))
|
||||
using (var reader = new StreamReader(stream))
|
||||
{
|
||||
original = reader.ReadToEnd();
|
||||
}
|
||||
|
||||
// Replace all newlines with spaces to produce the inline format
|
||||
var inlinePem = original.Replace("\r\n", " ").Replace('\n', ' ').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];
|
||||
|
||||
Reference in New Issue
Block a user