Skip to content

Commit 9b22a2d

Browse files
authored
Merge pull request #280 from caleblloyd/f_ssl_ca
Add CA Certificate validation. Fixes #278
2 parents 989a8ec + acead3a commit 9b22a2d

File tree

10 files changed

+199
-82
lines changed

10 files changed

+199
-82
lines changed
3.95 KB
Binary file not shown.

.gitattributes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@
1212
*.ttf binary
1313
*.woff binary
1414
*.woff2 binary
15+
*.pfx binary
16+

docs/content/connection-options.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,18 @@ These are the options that need to be used in order to configure a connection to
7878
<tr>
7979
<td>Certificate File, CertificateFile</td>
8080
<td></td>
81-
<td>This option specifies the path to a certificate file in PKCS #12 format (.pfx). </td>
81+
<td>This option specifies the path to a certificate file in a PEM Encoded (.pem) or PKCS #12 (.pfx) format. </td>
8282
</tr>
8383
<tr>
8484
<td>Certificate Password, CertificatePassword </td>
8585
<td></td>
8686
<td>Specifies a password that is used in conjunction with a certificate specified using the option CertificateFile. Not required if the certificate file is not password protected.</td>
8787
</tr>
88+
<tr>
89+
<td>CA Certificate File, CACertificateFile</td>
90+
<td></td>
91+
<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>
92+
</tr>
8893
</table>
8994

9095
Connection Pooling Options

src/MySqlConnector/MySqlClient/MySqlConnectionStringBuilder.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ public string CertificatePassword
6666
set => MySqlConnectionStringOption.CertificatePassword.SetValue(this, value);
6767
}
6868

69+
public string CACertificateFile
70+
{
71+
get => MySqlConnectionStringOption.CACertificateFile.GetValue(this);
72+
set => MySqlConnectionStringOption.CACertificateFile.SetValue(this, value);
73+
}
74+
6975
// Connection Pooling Options
7076
public bool Pooling
7177
{
@@ -238,6 +244,7 @@ internal abstract class MySqlConnectionStringOption
238244
public static readonly MySqlConnectionStringOption<MySqlSslMode> SslMode;
239245
public static readonly MySqlConnectionStringOption<string> CertificateFile;
240246
public static readonly MySqlConnectionStringOption<string> CertificatePassword;
247+
public static readonly MySqlConnectionStringOption<string> CACertificateFile;
241248

242249
// Connection Pooling Options
243250
public static readonly MySqlConnectionStringOption<bool> Pooling;
@@ -322,6 +329,10 @@ static MySqlConnectionStringOption()
322329
keys: new[] { "CertificatePassword", "Certificate Password" },
323330
defaultValue: null));
324331

332+
AddOption(CACertificateFile = new MySqlConnectionStringOption<string>(
333+
keys: new[] { "CACertificateFile", "CA Certificate File" },
334+
defaultValue: null));
335+
325336
// Connection Pooling Options
326337
AddOption(Pooling = new MySqlConnectionStringOption<bool>(
327338
keys: new[] { "Pooling" },

src/MySqlConnector/Serialization/ConnectionSettings.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public ConnectionSettings(MySqlConnectionStringBuilder csb)
3333
SslMode = csb.SslMode;
3434
CertificateFile = csb.CertificateFile;
3535
CertificatePassword = csb.CertificatePassword;
36+
CACertificateFile = csb.CACertificateFile;
3637

3738
// Connection Pooling Options
3839
Pooling = csb.Pooling;
@@ -73,6 +74,7 @@ public ConnectionSettings(MySqlConnectionStringBuilder csb)
7374
public MySqlSslMode SslMode { get; }
7475
public string CertificateFile { get; }
7576
public string CertificatePassword { get; }
77+
public string CACertificateFile { get; }
7678

7779
// Connection Pooling Options
7880
public bool Pooling { get; }

src/MySqlConnector/Serialization/MySqlSession.cs

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -537,36 +537,66 @@ private async Task InitSslAsync(ProtocolCapabilities serverCapabilities, Connect
537537
catch (CryptographicException ex)
538538
{
539539
if (!File.Exists(cs.CertificateFile))
540-
throw new MySqlException("Cannot find SSL Certificate File", ex);
541-
throw new MySqlException("Either the SSL Certificate Password is incorrect or the SSL Certificate File is invalid", ex);
540+
throw new MySqlException("Cannot find Certificate File", ex);
541+
throw new MySqlException("Either the Certificate Password is incorrect or the Certificate File is invalid", ex);
542542
}
543543
}
544544

545-
Func<object, string, X509CertificateCollection, X509Certificate, string[], X509Certificate> localCertificateCb =
546-
(lcbSender, lcbTargetHost, lcbLocalCertificates, lcbRemoteCertificate, lcbAcceptableIssuers) => lcbLocalCertificates[0];
545+
X509Chain caCertificateChain = null;
546+
if (cs.CACertificateFile != null)
547+
{
548+
try
549+
{
550+
var caCertificate = new X509Certificate2(cs.CACertificateFile);
551+
caCertificateChain = new X509Chain
552+
{
553+
ChainPolicy =
554+
{
555+
RevocationMode = X509RevocationMode.NoCheck,
556+
VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority
557+
}
558+
};
559+
caCertificateChain.ChainPolicy.ExtraStore.Add(caCertificate);
560+
}
561+
catch (CryptographicException ex)
562+
{
563+
if (!File.Exists(cs.CACertificateFile))
564+
throw new MySqlException("Cannot find CA Certificate File", ex);
565+
throw new MySqlException("The CA Certificate File is invalid", ex);
566+
}
567+
}
568+
569+
X509Certificate LocalCertificateCb(object lcbSender, string lcbTargetHost, X509CertificateCollection lcbLocalCertificates, X509Certificate lcbRemoteCertificate, string[] lcbAcceptableIssuers) => lcbLocalCertificates[0];
570+
571+
bool RemoteCertificateCb(object rcbSender, X509Certificate rcbCertificate, X509Chain rcbChain, SslPolicyErrors rcbPolicyErrors)
572+
{
573+
if (cs.SslMode == MySqlSslMode.Preferred || cs.SslMode == MySqlSslMode.Required)
574+
return true;
547575

548-
Func<object, X509Certificate, X509Chain, SslPolicyErrors, bool> remoteCertificateCb =
549-
(rcbSender, rcbCertificate, rcbChain, rcbPolicyErrors) =>
576+
if ((rcbPolicyErrors & SslPolicyErrors.RemoteCertificateChainErrors) != 0 && caCertificateChain != null)
550577
{
551-
switch (rcbPolicyErrors)
578+
if (caCertificateChain.Build((X509Certificate2) rcbCertificate))
552579
{
553-
case SslPolicyErrors.None:
554-
return true;
555-
case SslPolicyErrors.RemoteCertificateNameMismatch:
556-
return cs.SslMode != MySqlSslMode.VerifyFull;
557-
default:
558-
return cs.SslMode == MySqlSslMode.Preferred || cs.SslMode == MySqlSslMode.Required;
580+
var chainStatus = caCertificateChain.ChainStatus[0].Status & ~X509ChainStatusFlags.UntrustedRoot;
581+
if (chainStatus == X509ChainStatusFlags.NoError)
582+
rcbPolicyErrors &= ~SslPolicyErrors.RemoteCertificateChainErrors;
559583
}
560-
};
584+
}
585+
586+
if (cs.SslMode == MySqlSslMode.VerifyCA)
587+
rcbPolicyErrors &= ~SslPolicyErrors.RemoteCertificateNameMismatch;
588+
589+
return rcbPolicyErrors == SslPolicyErrors.None;
590+
}
561591

562592
SslStream sslStream;
563593
if (clientCertificates == null)
564594
sslStream = new SslStream(m_networkStream, false,
565-
new RemoteCertificateValidationCallback(remoteCertificateCb));
595+
new RemoteCertificateValidationCallback((Func<object, X509Certificate, X509Chain, SslPolicyErrors, bool>) RemoteCertificateCb));
566596
else
567597
sslStream = new SslStream(m_networkStream, false,
568-
new RemoteCertificateValidationCallback(remoteCertificateCb),
569-
new LocalCertificateSelectionCallback(localCertificateCb));
598+
new RemoteCertificateValidationCallback((Func<object, X509Certificate, X509Chain, SslPolicyErrors, bool>) RemoteCertificateCb),
599+
new LocalCertificateSelectionCallback((Func<object, string, X509CertificateCollection, X509Certificate, string[], X509Certificate>) LocalCertificateCb));
570600

571601
// SslProtocols.Tls1.2 throws an exception in Windows, see https://github.com/mysql-net/MySqlConnector/pull/101
572602
var sslProtocols = SslProtocols.Tls | SslProtocols.Tls11;

tests/MySqlConnector.Tests/MySqlConnectionStringBuilderTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public void Defaults()
2828
Assert.Equal(false, csb.BufferResultSets);
2929
Assert.Equal(180u, csb.ConnectionIdleTimeout);
3030
Assert.Equal(false, csb.ForceSynchronous);
31+
Assert.Equal(null, csb.CACertificateFile);
3132
#endif
3233
Assert.Equal(0u, csb.Keepalive);
3334
Assert.Equal(100u, csb.MaximumPoolSize);
@@ -70,6 +71,7 @@ public void ParseConnectionString()
7071
"connectionidletimeout=30;" +
7172
"bufferresultsets=true;" +
7273
"forcesynchronous=true;" +
74+
"ca certificate file=ca.pem;" +
7375
#endif
7476
"Keep Alive=90;" +
7577
"minpoolsize=5;" +
@@ -98,6 +100,7 @@ public void ParseConnectionString()
98100
Assert.Equal(true, csb.BufferResultSets);
99101
Assert.Equal(30u, csb.ConnectionIdleTimeout);
100102
Assert.Equal(true, csb.ForceSynchronous);
103+
Assert.Equal("ca.pem", csb.CACertificateFile);
101104
#endif
102105
Assert.Equal(90u, csb.Keepalive);
103106
Assert.Equal(15u, csb.MaximumPoolSize);

tests/SideBySide/Attributes.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,14 @@ public SslRequiredConnectionFactAttribute()
107107
Skip = "SSL not explicitly required";
108108
}
109109
}
110+
111+
public class SslRequiredConnectionTheoryAttribute : TheoryAttribute
112+
{
113+
public SslRequiredConnectionTheoryAttribute()
114+
{
115+
var csb = AppConfig.CreateConnectionStringBuilder();
116+
if(csb.SslMode == MySqlSslMode.None || csb.SslMode == MySqlSslMode.Preferred)
117+
Skip = "SSL not explicitly required";
118+
}
119+
}
110120
}

tests/SideBySide/ConnectAsync.cs

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -115,70 +115,6 @@ public async Task ConnectKeepAlive()
115115
}
116116
}
117117

118-
[SslRequiredConnectionFact]
119-
public async Task ConnectSslPreferred()
120-
{
121-
var csb = AppConfig.CreateConnectionStringBuilder();
122-
string requiredSslVersion;
123-
using (var connection = new MySqlConnection(csb.ConnectionString))
124-
{
125-
using (var cmd = connection.CreateCommand())
126-
{
127-
await connection.OpenAsync();
128-
cmd.CommandText = "SHOW SESSION STATUS LIKE 'Ssl_version'";
129-
requiredSslVersion = (string)await cmd.ExecuteScalarAsync();
130-
}
131-
}
132-
Assert.False(string.IsNullOrWhiteSpace(requiredSslVersion));
133-
134-
csb.SslMode = MySqlSslMode.Preferred;
135-
using (var connection = new MySqlConnection(csb.ConnectionString))
136-
{
137-
using (var cmd = connection.CreateCommand())
138-
{
139-
await connection.OpenAsync();
140-
cmd.CommandText = "SHOW SESSION STATUS LIKE 'Ssl_version'";
141-
var preferredSslVersion = (string)await cmd.ExecuteScalarAsync();
142-
Assert.Equal(requiredSslVersion, preferredSslVersion);
143-
}
144-
}
145-
}
146-
147-
[SslRequiredConnectionFact]
148-
public async Task ConnectSslClientCertificate()
149-
{
150-
var csb = AppConfig.CreateConnectionStringBuilder();
151-
csb.CertificateFile = Path.Combine(AppConfig.CertsPath, "ssl-client.pfx");
152-
csb.CertificatePassword = "";
153-
using (var connection = new MySqlConnection(csb.ConnectionString))
154-
{
155-
using (var cmd = connection.CreateCommand())
156-
{
157-
await connection.OpenAsync();
158-
cmd.CommandText = "SHOW SESSION STATUS LIKE 'Ssl_version'";
159-
var sslVersion = (string)await cmd.ExecuteScalarAsync();
160-
Assert.False(string.IsNullOrWhiteSpace(sslVersion));
161-
}
162-
}
163-
}
164-
165-
[SslRequiredConnectionFact]
166-
public async Task ConnectSslBadClientCertificate()
167-
{
168-
var csb = AppConfig.CreateConnectionStringBuilder();
169-
csb.CertificateFile = Path.Combine(AppConfig.CertsPath, "non-ca-client.pfx");
170-
csb.CertificatePassword = "";
171-
using (var connection = new MySqlConnection(csb.ConnectionString))
172-
{
173-
#if BASELINE
174-
var exType = typeof(IOException);
175-
#else
176-
var exType = typeof(MySqlException);
177-
#endif
178-
await Assert.ThrowsAsync(exType, async () => await connection.OpenAsync());
179-
}
180-
}
181-
182118
[Fact]
183119
public async Task ConnectionDatabase()
184120
{

tests/SideBySide/SslTests.cs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using System;
2+
using System.Data;
3+
using System.IO;
4+
using System.Threading.Tasks;
5+
using MySql.Data.MySqlClient;
6+
using Xunit;
7+
8+
namespace SideBySide
9+
{
10+
public class SslTests : IClassFixture<DatabaseFixture>
11+
{
12+
public SslTests(DatabaseFixture database)
13+
{
14+
m_database = database;
15+
}
16+
17+
[SslRequiredConnectionFact]
18+
public async Task ConnectSslPreferred()
19+
{
20+
var csb = AppConfig.CreateConnectionStringBuilder();
21+
string requiredSslVersion;
22+
using (var connection = new MySqlConnection(csb.ConnectionString))
23+
{
24+
using (var cmd = connection.CreateCommand())
25+
{
26+
await connection.OpenAsync();
27+
cmd.CommandText = "SHOW SESSION STATUS LIKE 'Ssl_version'";
28+
requiredSslVersion = (string)await cmd.ExecuteScalarAsync();
29+
}
30+
}
31+
Assert.False(string.IsNullOrWhiteSpace(requiredSslVersion));
32+
33+
csb.SslMode = MySqlSslMode.Preferred;
34+
using (var connection = new MySqlConnection(csb.ConnectionString))
35+
{
36+
using (var cmd = connection.CreateCommand())
37+
{
38+
await connection.OpenAsync();
39+
cmd.CommandText = "SHOW SESSION STATUS LIKE 'Ssl_version'";
40+
var preferredSslVersion = (string)await cmd.ExecuteScalarAsync();
41+
Assert.Equal(requiredSslVersion, preferredSslVersion);
42+
}
43+
}
44+
}
45+
46+
[SslRequiredConnectionTheory]
47+
[InlineData("ssl-client.pfx", null, null)]
48+
[InlineData("ssl-client-pw-test.pfx", "test", null)]
49+
[InlineData("ssl-client-cert.pem", null, null)]
50+
#if !BASELINE
51+
[InlineData("ssl-client.pfx", null, "ssl-ca-cert.pem")]
52+
[InlineData("ssl-client-pw-test.pfx", "test", "ssl-ca-cert.pem")]
53+
[InlineData("ssl-client-cert.pem", null, "ssl-ca-cert.pem")]
54+
#endif
55+
public async Task ConnectSslClientCertificate(string certFile, string certFilePassword, string caCertFile)
56+
{
57+
var csb = AppConfig.CreateConnectionStringBuilder();
58+
csb.CertificateFile = Path.Combine(AppConfig.CertsPath, certFile);
59+
csb.CertificatePassword = certFilePassword;
60+
if (caCertFile != null)
61+
{
62+
csb.SslMode = MySqlSslMode.VerifyCA;
63+
#if !BASELINE
64+
csb.CACertificateFile = Path.Combine(AppConfig.CertsPath, caCertFile);
65+
#endif
66+
}
67+
using (var connection = new MySqlConnection(csb.ConnectionString))
68+
{
69+
using (var cmd = connection.CreateCommand())
70+
{
71+
await connection.OpenAsync();
72+
cmd.CommandText = "SHOW SESSION STATUS LIKE 'Ssl_version'";
73+
var sslVersion = (string)await cmd.ExecuteScalarAsync();
74+
Assert.False(string.IsNullOrWhiteSpace(sslVersion));
75+
}
76+
}
77+
}
78+
79+
[SslRequiredConnectionFact]
80+
public async Task ConnectSslBadClientCertificate()
81+
{
82+
var csb = AppConfig.CreateConnectionStringBuilder();
83+
csb.CertificateFile = Path.Combine(AppConfig.CertsPath, "non-ca-client.pfx");
84+
csb.CertificatePassword = "";
85+
using (var connection = new MySqlConnection(csb.ConnectionString))
86+
{
87+
#if BASELINE
88+
var exType = typeof(IOException);
89+
#else
90+
var exType = typeof(MySqlException);
91+
#endif
92+
await Assert.ThrowsAsync(exType, async () => await connection.OpenAsync());
93+
}
94+
}
95+
96+
#if BASELINE
97+
[Fact(Skip = "MySql.Data does not support CACertificateFile")]
98+
#else
99+
[SslRequiredConnectionFact]
100+
#endif
101+
public async Task ConnectSslBadCaCertificate()
102+
{
103+
var csb = AppConfig.CreateConnectionStringBuilder();
104+
csb.CertificateFile = Path.Combine(AppConfig.CertsPath, "ssl-client-cert.pem");
105+
csb.SslMode = MySqlSslMode.VerifyCA;
106+
#if !BASELINE
107+
csb.CACertificateFile = Path.Combine(AppConfig.CertsPath, "non-ca-client-cert.pem");
108+
#endif
109+
using (var connection = new MySqlConnection(csb.ConnectionString))
110+
{
111+
await Assert.ThrowsAsync(typeof(MySqlException), async () => await connection.OpenAsync());
112+
}
113+
}
114+
115+
readonly DatabaseFixture m_database;
116+
}
117+
118+
}

0 commit comments

Comments
 (0)