Skip to content

Commit 5159fd1

Browse files
committed
Add SslCert and SslKey connection string options. Fixes #641
1 parent bdc774f commit 5159fd1

File tree

10 files changed

+182
-24
lines changed

10 files changed

+182
-24
lines changed

docs/content/connection-options.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,27 @@ These are the options that need to be used in order to configure a connection to
9393
<tr>
9494
<td>Certificate File, CertificateFile</td>
9595
<td></td>
96-
<td>Specifies the path to a certificate file in PKCS #12 (.pfx) format containing a bundled Certificate and Private Key used for Mutual Authentication. To create a PKCS #12 bundle from a PEM encoded Certificate and Key, use <code>openssl pkcs12 -in cert.pem -inkey key.pem -export -out bundle.pfx</code></td>
96+
<td>Specifies the path to a certificate file in PKCS #12 (.pfx) format containing a bundled Certificate and Private Key used for Mutual Authentication. To create a PKCS #12 bundle from a PEM encoded Certificate and Key, use <code>openssl pkcs12 -in cert.pem -inkey key.pem -export -out bundle.pfx</code>. This option should not be specified if <code>SslCert</code> and <code>SslKey</code> are used.</td>
9797
</tr>
9898
<tr>
9999
<td>Certificate Password, CertificatePassword</td>
100100
<td></td>
101101
<td>Specifies the password for the certificate specified using the <code>CertificateFile</code> option. Not required if the certificate file is not password protected.</td>
102102
</tr>
103+
<tr>
104+
<td>SslCert, Ssl-Cert</td>
105+
<td></td>
106+
<td>Specifies the path to the client’s SSL certificate file in PEM format. <code>SslKey</code> must also be specified, and <code>CertificateFile</code> should not be.</td>
107+
</tr>
108+
<tr>
109+
<td>SslKey, Ssl-Key</td>
110+
<td></td>
111+
<td>Specifies the path to the client’s SSL private key in PEM format. <code>SslCert</code> must also be specified, and <code>CertificateFile</code> should not be.</td>
112+
</tr>
103113
<tr>
104114
<td>CA Certificate File, CACertificateFile, SslCa, Ssl-Ca</td>
105115
<td></td>
106-
<td>This option specifies the path to a CA certificate file in a PEM Encoded (.pem) format. This should be used in with <code>SslMode=VerifyCA</code> or <code>SslMode=VerifyFull</code> to enable verification of a CA certificate that is not trusted by the Operating System’s certificate store.</td>
116+
<td>This option specifies the path to a CA certificate file in a PEM Encoded (.pem) format. This should be used with <code>SslMode=VerifyCA</code> or <code>SslMode=VerifyFull</code> to enable verification of a CA certificate that is not trusted by the Operating System’s certificate store.</td>
107117
</tr>
108118
<tr>
109119
<td>Certificate Store Location, CertificateStoreLocation</td>

docs/content/tutorials/migrating-from-connector-net.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,4 @@ The following bugs in Connector/NET are fixed by switching to MySqlConnector. (~
165165
* [#94075](https://bugs.mysql.com/bug.php?id=94075): `MySqlCommand.Cancel` throws exception
166166
* [#94760](https://bugs.mysql.com/bug.php?id=94760): `MySqlConnection.OpenAsync(CancellationToken)` doesn’t respect cancellation token
167167
* [#95348](https://bugs.mysql.com/bug.php?id=95348): Inefficient query when executing stored procedures
168+
* [#95436](https://bugs.mysql.com/bug.php?id=95436): Client doesn't authenticate with PEM certificate

src/MySqlConnector/Core/ConnectionSettings.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ public ConnectionSettings(MySqlConnectionStringBuilder csb)
4646
SslMode = csb.SslMode;
4747
CertificateFile = csb.CertificateFile;
4848
CertificatePassword = csb.CertificatePassword;
49+
SslCertificateFile = csb.SslCert;
50+
SslKeyFile = csb.SslKey;
4951
CACertificateFile = csb.SslCa;
5052
CertificateStoreLocation = csb.CertificateStoreLocation;
5153
CertificateThumbprint = csb.CertificateThumbprint;
@@ -129,6 +131,8 @@ private static MySqlGuidFormat GetEffectiveGuidFormat(MySqlGuidFormat guidFormat
129131
public string CertificateFile { get; }
130132
public string CertificatePassword { get; }
131133
public string CACertificateFile { get; }
134+
public string SslCertificateFile { get; }
135+
public string SslKeyFile { get; }
132136
public MySqlCertificateStoreLocation CertificateStoreLocation { get; }
133137
public string CertificateThumbprint { get; }
134138

src/MySqlConnector/Core/ServerSession.cs

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -545,10 +545,10 @@ private async Task<PayloadData> SendEncryptedPasswordAsync(
545545
CancellationToken cancellationToken)
546546
{
547547
// load the RSA public key
548-
RSA rsa;
548+
RSAParameters rsaParameters;
549549
try
550550
{
551-
rsa = Utility.DecodeX509PublicKey(rsaPublicKey);
551+
rsaParameters = Utility.GetRsaParameters(rsaPublicKey);
552552
}
553553
catch (Exception ex)
554554
{
@@ -560,8 +560,10 @@ private async Task<PayloadData> SendEncryptedPasswordAsync(
560560
var passwordBytes = Encoding.UTF8.GetBytes(cs.Password);
561561
Array.Resize(ref passwordBytes, passwordBytes.Length + 1);
562562

563-
using (rsa)
563+
using (var rsa = RSA.Create())
564564
{
565+
rsa.ImportParameters(rsaParameters);
566+
565567
// XOR the password bytes with the challenge
566568
AuthPluginData = Utility.TrimZeroByte(switchRequest.Data);
567569
for (var i = 0; i < passwordBytes.Length; i++)
@@ -957,7 +959,81 @@ private async Task InitSslAsync(ProtocolCapabilities serverCapabilities, Connect
957959
}
958960
}
959961

960-
if (cs.CertificateFile != null)
962+
if (cs.SslCertificateFile is object && cs.SslKeyFile is object)
963+
{
964+
#if !NETSTANDARD1_3 && !NETSTANDARD2_0
965+
m_logArguments[1] = cs.SslKeyFile;
966+
Log.Debug("Session{0} loading client key from KeyFile '{1}'", m_logArguments);
967+
string keyPem;
968+
try
969+
{
970+
keyPem = File.ReadAllText(cs.SslKeyFile);
971+
}
972+
catch (Exception ex)
973+
{
974+
Log.Error(ex, "Session{0} couldn't load client key from KeyFile '{1}'", m_logArguments);
975+
throw new MySqlException("Could not load client key file: " + cs.SslKeyFile, ex);
976+
}
977+
978+
RSAParameters rsaParameters;
979+
try
980+
{
981+
rsaParameters = Utility.GetRsaParameters(keyPem);
982+
}
983+
catch (FormatException ex)
984+
{
985+
Log.Error(ex, "Session{0} couldn't load client key from KeyFile '{1}'", m_logArguments);
986+
throw new MySqlException("Could not load the client key from " + cs.SslKeyFile, ex);
987+
}
988+
989+
try
990+
{
991+
RSA rsa;
992+
try
993+
{
994+
// SslStream on Windows needs a KeyContainerName to be set
995+
var csp = new CspParameters
996+
{
997+
KeyContainerName = new Guid().ToString(),
998+
};
999+
rsa = new RSACryptoServiceProvider(csp)
1000+
{
1001+
PersistKeyInCsp = true,
1002+
};
1003+
}
1004+
catch (PlatformNotSupportedException)
1005+
{
1006+
rsa = RSA.Create();
1007+
}
1008+
rsa.ImportParameters(rsaParameters);
1009+
1010+
#if !NETCOREAPP2_1
1011+
var certificate = new X509Certificate2(cs.SslCertificateFile, "", X509KeyStorageFlags.MachineKeySet)
1012+
{
1013+
PrivateKey = rsa,
1014+
};
1015+
#else
1016+
X509Certificate2 certificate;
1017+
using (var publicCertificate = new X509Certificate2(cs.SslCertificateFile))
1018+
certificate = publicCertificate.CopyWithPrivateKey(rsa);
1019+
#endif
1020+
1021+
m_clientCertificate = certificate;
1022+
clientCertificates = new X509CertificateCollection { certificate };
1023+
}
1024+
1025+
catch (CryptographicException ex)
1026+
{
1027+
Log.Error(ex, "Session{0} couldn't load client key from KeyFile '{1}'", m_logArguments);
1028+
if (!File.Exists(cs.SslCertificateFile))
1029+
throw new MySqlException("Cannot find client certificate file: " + cs.SslCertificateFile, ex);
1030+
throw new MySqlException("Could not load the client key from " + cs.SslKeyFile, ex);
1031+
}
1032+
#else
1033+
throw new NotSupportedException("SslCert and SslKey connection string options are not supported in netstandard1.3 or netstandard2.0.");
1034+
#endif
1035+
}
1036+
else if (cs.CertificateFile != null)
9611037
{
9621038
try
9631039
{

src/MySqlConnector/MySql.Data.MySqlClient/MySqlConnectionStringBuilder.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ public string CertificatePassword
8585
set => MySqlConnectionStringOption.CertificatePassword.SetValue(this, value);
8686
}
8787

88+
public string SslCert
89+
{
90+
get => MySqlConnectionStringOption.SslCert.GetValue(this);
91+
set => MySqlConnectionStringOption.SslCert.SetValue(this, value);
92+
}
93+
94+
public string SslKey
95+
{
96+
get => MySqlConnectionStringOption.SslKey.GetValue(this);
97+
set => MySqlConnectionStringOption.SslKey.SetValue(this, value);
98+
}
99+
88100
[Obsolete("Use SslCa instead.")]
89101
public string CACertificateFile
90102
{
@@ -360,6 +372,8 @@ internal abstract class MySqlConnectionStringOption
360372
public static readonly MySqlConnectionStringOption<MySqlCertificateStoreLocation> CertificateStoreLocation;
361373
public static readonly MySqlConnectionStringOption<string> CertificateThumbprint;
362374
public static readonly MySqlConnectionStringOption<string> SslCa;
375+
public static readonly MySqlConnectionStringOption<string> SslCert;
376+
public static readonly MySqlConnectionStringOption<string> SslKey;
363377

364378
// Connection Pooling Options
365379
public static readonly MySqlConnectionStringOption<bool> Pooling;
@@ -472,6 +486,14 @@ static MySqlConnectionStringOption()
472486
keys: new[] { "CACertificateFile", "CA Certificate File", "SslCa", "Ssl-Ca" },
473487
defaultValue: null));
474488

489+
AddOption(SslCert = new MySqlConnectionStringOption<string>(
490+
keys: new[] { "SslCert", "Ssl-Cert" },
491+
defaultValue: null));
492+
493+
AddOption(SslKey = new MySqlConnectionStringOption<string>(
494+
keys: new[] { "SslKey", "Ssl-Key" },
495+
defaultValue: null));
496+
475497
AddOption(CertificateStoreLocation = new MySqlConnectionStringOption<MySqlCertificateStoreLocation>(
476498
keys: new[] { "CertificateStoreLocation", "Certificate Store Location" },
477499
defaultValue: MySqlCertificateStoreLocation.None));

src/MySqlConnector/Utilities/Utility.cs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,33 @@ public static unsafe void Convert(this Encoder encoder, ReadOnlySpan<char> chars
6767
#endif
6868

6969
/// <summary>
70-
/// Loads a RSA public key from a PEM string.
70+
/// Loads a RSA key from a PEM string.
7171
/// </summary>
72-
/// <param name="publicKey">The public key, in PEM format.</param>
73-
/// <returns>An RSA public key, or <c>null</c> on failure.</returns>
74-
public static RSA DecodeX509PublicKey(string publicKey)
72+
/// <param name="key">The key, in PEM format.</param>
73+
/// <returns>An RSA key.</returns>
74+
public static RSAParameters GetRsaParameters(string key)
7575
{
76-
var x509Key = System.Convert.FromBase64String(publicKey.Replace("-----BEGIN PUBLIC KEY-----", "").Replace("-----END PUBLIC KEY-----", ""));
77-
var parameters = GetKeyParameters(x509Key, false);
78-
var rsa = RSA.Create();
79-
rsa.ImportParameters(parameters);
80-
return rsa;
76+
bool isPrivate;
77+
if (key.StartsWith("-----BEGIN RSA PRIVATE KEY-----", StringComparison.Ordinal))
78+
{
79+
key = key.Replace("-----BEGIN RSA PRIVATE KEY-----", "").Replace("-----END RSA PRIVATE KEY-----", "");
80+
isPrivate = true;
81+
}
82+
else if (key.StartsWith("-----BEGIN PUBLIC KEY-----", StringComparison.Ordinal))
83+
{
84+
key = key.Replace("-----BEGIN PUBLIC KEY-----", "").Replace("-----END PUBLIC KEY-----", "");
85+
isPrivate = false;
86+
}
87+
else
88+
{
89+
throw new FormatException("Unrecognized PEM header: " + key.Substring(0, Math.Min(key.Length, 80)));
90+
}
91+
92+
return GetRsaParameters(System.Convert.FromBase64String(key), isPrivate);
8193
}
8294

8395
// Derived from: https://stackoverflow.com/a/32243171/, https://stackoverflow.com/a/26978561/, http://luca.ntop.org/Teaching/Appunti/asn1.html
84-
internal static RSAParameters GetKeyParameters(ReadOnlySpan<byte> data, bool isPrivate)
96+
private static RSAParameters GetRsaParameters(ReadOnlySpan<byte> data, bool isPrivate)
8597
{
8698
// read header (30 81 xx, or 30 82 xx xx)
8799
if (data[0] != 0x30)

tests/MySqlConnector.Tests/MySqlConnectionStringBuilderTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ public void Defaults()
5959
Assert.Null(csb.ServerSPN);
6060
#endif
6161
Assert.Null(csb.SslCa);
62+
Assert.Null(csb.SslCert);
63+
Assert.Null(csb.SslKey);
6264
Assert.Equal(MySqlSslMode.Preferred, csb.SslMode);
6365
Assert.True(csb.TreatTinyAsBoolean);
6466
Assert.False(csb.UseCompression);
@@ -120,6 +122,8 @@ public void ParseConnectionString()
120122
"pwd=Pass1234;" +
121123
"Treat Tiny As Boolean=false;" +
122124
"ssl-ca=ca.pem;" +
125+
"ssl-cert=client-cert.pem;" +
126+
"ssl-key=client-key.pem;" +
123127
"ssl mode=verifyca;" +
124128
"Uid=username;" +
125129
"useaffectedrows=true"
@@ -172,6 +176,8 @@ public void ParseConnectionString()
172176
Assert.Equal("db-server", csb.Server);
173177
Assert.False(csb.TreatTinyAsBoolean);
174178
Assert.Equal("ca.pem", csb.SslCa);
179+
Assert.Equal("client-cert.pem", csb.SslCert);
180+
Assert.Equal("client-key.pem", csb.SslKey);
175181
Assert.Equal(MySqlSslMode.VerifyCA, csb.SslMode);
176182
Assert.True(csb.UseAffectedRows);
177183
Assert.True(csb.UseCompression);

tests/MySqlConnector.Tests/UtilityTests.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ public void ParseTimeSpanFails(string input)
6060
[Fact]
6161
public void DecodePublicKey()
6262
{
63-
var publicKey = Convert.FromBase64String(c_publicKey.Replace("-----BEGIN PUBLIC KEY-----", "").Replace("-----END PUBLIC KEY-----", ""));
64-
var parameters = Utility.GetKeyParameters(publicKey, isPrivate: false);
63+
var parameters = Utility.GetRsaParameters(c_publicKey);
6564
Console.WriteLine(BitConverter.ToString(parameters.Modulus));
6665
Assert.Equal(s_modulus, parameters.Modulus);
6766
Assert.Equal(s_exponent, parameters.Exponent);
@@ -70,8 +69,7 @@ public void DecodePublicKey()
7069
[Fact]
7170
public void DecodePrivateKey()
7271
{
73-
var privateKey = Convert.FromBase64String(c_privateKey.Replace("-----BEGIN RSA PRIVATE KEY-----", "").Replace("-----END RSA PRIVATE KEY-----", ""));
74-
var parameters = Utility.GetKeyParameters(privateKey, isPrivate: true);
72+
var parameters = Utility.GetRsaParameters(c_privateKey);
7573
Console.WriteLine(BitConverter.ToString(parameters.Modulus));
7674
Assert.Equal(s_modulus, parameters.Modulus);
7775
Assert.Equal(s_exponent, parameters.Exponent);

tests/SideBySide/SslTests.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,33 @@ public async Task ConnectSslClientCertificate(string certFile, string certFilePa
5656
csb.SslMode = MySqlSslMode.VerifyCA;
5757
csb.SslCa = Path.Combine(AppConfig.CertsPath, caCertFile);
5858
}
59-
using (var connection = new MySqlConnection(csb.ConnectionString))
59+
await DoTestSsl(csb.ConnectionString);
60+
}
61+
62+
#if !NETCOREAPP1_1_2 && !NETCOREAPP2_0
63+
[SkippableTheory(ConfigSettings.RequiresSsl | ConfigSettings.KnownClientCertificate)]
64+
[InlineData("ssl-client-cert.pem", "ssl-client-key.pem", null)]
65+
#if !BASELINE
66+
[InlineData("ssl-client-cert.pem", "ssl-client-key.pem", "ssl-ca-cert.pem")] // https://bugs.mysql.com/bug.php?id=95436
67+
#endif
68+
public async Task ConnectSslClientCertificatePem(string certFile, string keyFile, string caCertFile)
69+
{
70+
var csb = AppConfig.CreateConnectionStringBuilder();
71+
csb.CertificateFile = null;
72+
csb.SslCert = Path.Combine(AppConfig.CertsPath, certFile);
73+
csb.SslKey = Path.Combine(AppConfig.CertsPath, keyFile);
74+
if (caCertFile != null)
75+
{
76+
csb.SslMode = MySqlSslMode.VerifyCA;
77+
csb.SslCa = Path.Combine(AppConfig.CertsPath, caCertFile);
78+
}
79+
await DoTestSsl(csb.ConnectionString);
80+
}
81+
#endif
82+
83+
private async Task DoTestSsl(string connectionString)
84+
{
85+
using (var connection = new MySqlConnection(connectionString))
6086
{
6187
using (var cmd = connection.CreateCommand())
6288
{
@@ -68,7 +94,7 @@ public async Task ConnectSslClientCertificate(string certFile, string certFilePa
6894
Assert.True(connection.SslIsMutuallyAuthenticated);
6995
#endif
7096
cmd.CommandText = "SHOW SESSION STATUS LIKE 'Ssl_version'";
71-
var sslVersion = (string)await cmd.ExecuteScalarAsync();
97+
var sslVersion = (string) await cmd.ExecuteScalarAsync();
7298
Assert.False(string.IsNullOrWhiteSpace(sslVersion));
7399
}
74100
}

tests/SideBySide/TestUtilities.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,11 @@ public static string GetSkipReason(ServerFeatures serverFeatures, ConfigSettings
8585
return "Requires SslMode=Required or lower in connection string";
8686
}
8787

88-
if (configSettings.HasFlag(ConfigSettings.KnownClientCertificate) && !(csb.CertificateFile?.EndsWith("ssl-client.pfx", StringComparison.OrdinalIgnoreCase) ?? false))
89-
return "Requires CertificateFile=client.pfx in connection string";
88+
if (configSettings.HasFlag(ConfigSettings.KnownClientCertificate))
89+
{
90+
if (!((csb.CertificateFile?.EndsWith("ssl-client.pfx", StringComparison.OrdinalIgnoreCase) ?? false) || (csb.SslKey?.EndsWith("ssl-client-key.pem", StringComparison.OrdinalIgnoreCase) ?? false)))
91+
return "Requires CertificateFile=client.pfx in connection string";
92+
}
9093

9194
if (configSettings.HasFlag(ConfigSettings.PasswordlessUser) && string.IsNullOrWhiteSpace(AppConfig.PasswordlessUser))
9295
return "Requires PasswordlessUser in config.json";

0 commit comments

Comments
 (0)