Skip to content

Commit 8267242

Browse files
committed
support domain patterns in certificate pinning
1 parent 223cf5c commit 8267242

File tree

5 files changed

+180
-6
lines changed

5 files changed

+180
-6
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## 2.3.5
2+
- support domain patterns in certificate pinning
3+
14
## 2.3.4
25
- vs : 17.14.17
36
- dotnet sdk 9.0.306

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ var html = await response.Content.ReadAsStringAsync();
4646

4747
## Certificate pining
4848

49+
### Usage
50+
4951
After creating a `SecureHttpClientHandler` object, call `AddCertificatePinner` to add one or more certificate pinner.
5052

5153
The request will fail if the certificate pin is not correct.
@@ -65,6 +67,21 @@ var response = await httpClient.GetAsync("https://www.github.com");
6567
var html = await response.Content.ReadAsStringAsync();
6668
```
6769

70+
### Domain patterns
71+
72+
`SecureHttpClient` behaves the same as `OkHttp`: pinning is per-hostname and/or per-wildcard pattern.
73+
74+
To pin both `example.com` and `www.example.com` you must configure both hostnames. Or you may use patterns to match sets of related domain names. The following forms are permitted:
75+
- Full domain name: you may pin an exact domain name like `www.example.com`. It won't match additional prefixes (`abc.www.example.com`) or suffixes (`example.com`).
76+
- Any number of subdomains: Use two asterisks like `**.example.com` to match any number of prefixes (`abc.www.example.com`, `www.example.com`) including no prefix at all (`example.com`). For most applications this is the best way to configure certificate pinning.
77+
- Exactly one subdomain: Use a single asterisk like `*.example.com` to match exactly one prefix (`www.example.com`, `api.example.com`). Be careful with this approach as no pinning will be enforced if additional prefixes are present, or if no prefixes are present.
78+
79+
Note that any other form is unsupported. You may not use asterisks in any position other than the leftmost label.
80+
81+
If multiple patterns match a hostname, any match is sufficient. For example, suppose pin A applies to *.example.com and pin B applies to `api.example.com`. Handshakes for `api.example.com` are valid if either A's or B's certificate is in the chain.
82+
83+
### Compute the pin
84+
6885
In order to compute the pin (SPKI fingerprint of the server's SSL certificate), you can execute the following command (here for `www.github.com` host):
6986
```shell
7087
openssl s_client -connect www.github.com:443 -servername www.github.com | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -noout -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64

SecureHttpClient.Test/CertificatePinnerTest.cs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
using System.Threading.Tasks;
1+
using System;
22
using SecureHttpClient.Test.Helpers;
3+
using System.Threading.Tasks;
34
using Xunit;
45

56
namespace SecureHttpClient.Test
67
{
78
public class CertificatePinnerTest : TestBase, IClassFixture<TestFixture>
89
{
910
private const string Hostname = @"www.howsmyssl.com";
11+
private const string Wildcard1 = @"*.howsmyssl.com";
12+
private const string Wildcard2 = @"**.howsmyssl.com";
13+
private const string InvalidPattern = @"*.*.howsmyssl.com";
1014
private const string Page = @"https://www.howsmyssl.com/a/check";
1115
private static readonly string[] PinsOk = { @"sha256/kXo1ykvfYulcwtgnY1/sOcAF+b8pHUNGzYsXaNADPpE=" };
1216
private static readonly string[] PinsKo = { @"sha256/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=" };
@@ -82,5 +86,72 @@ public async Task CertificatePinnerTest_EccCertificate_Failure()
8286
AddCertificatePinner(Hostname3, Pins3Ko);
8387
await AssertExtensions.ThrowsTrustFailureAsync(() => GetAsync(Page3));
8488
}
89+
90+
[Fact]
91+
public void CertificatePinnerTest_InvalidPattern()
92+
{
93+
Assert.Throws<ArgumentException>(() => AddCertificatePinner(InvalidPattern, PinsOk));
94+
}
95+
96+
[Fact]
97+
public async Task CertificatePinnerTest_Wildcard1_Success()
98+
{
99+
AddCertificatePinner(Wildcard1, PinsOk);
100+
await GetAsync(Page);
101+
}
102+
103+
[Fact]
104+
public async Task CertificatePinnerTest_Wildcard2_Success()
105+
{
106+
AddCertificatePinner(Wildcard2, PinsOk);
107+
await GetAsync(Page);
108+
}
109+
110+
[Fact]
111+
public async Task CertificatePinnerTest_Merge_Success()
112+
{
113+
AddCertificatePinner(Wildcard1, PinsKo);
114+
AddCertificatePinner(Hostname, PinsOk);
115+
await GetAsync(Page);
116+
}
117+
118+
[Fact]
119+
public async Task CertificatePinnerTest_Wildcard1_Failure()
120+
{
121+
AddCertificatePinner(Wildcard1, PinsKo);
122+
await AssertExtensions.ThrowsTrustFailureAsync(() => GetAsync(Page));
123+
}
124+
125+
[Fact]
126+
public async Task CertificatePinnerTest_Wildcard2_Failure()
127+
{
128+
AddCertificatePinner(Wildcard2, PinsKo);
129+
await AssertExtensions.ThrowsTrustFailureAsync(() => GetAsync(Page));
130+
}
131+
132+
[Fact]
133+
public async Task CertificatePinnerTest_Merge_Failure()
134+
{
135+
AddCertificatePinner(Wildcard1, PinsKo);
136+
AddCertificatePinner(Hostname, PinsKo);
137+
await AssertExtensions.ThrowsTrustFailureAsync(() => GetAsync(Page));
138+
}
139+
140+
[Theory]
141+
[InlineData("abc.example.com", "abc.example.com", true)]
142+
[InlineData("abc.example.com", "def.example.com", false)]
143+
[InlineData("*.example.com", "abc.example.com", true)]
144+
[InlineData("*.example.com", "example.com", false)]
145+
[InlineData("*.example.com", "abc.def.example.com", false)]
146+
[InlineData("*.example.com", "abc.example.org", false)]
147+
[InlineData("**.example.com", "example.com", true)]
148+
[InlineData("**.example.com", "abc.example.com", true)]
149+
[InlineData("**.example.com", "abc.def.example.com", true)]
150+
[InlineData("**.example.com", "abc.def.example.org", false)]
151+
public void CertificatePinnerTest_MatchesPattern(string pattern, string hostname, bool expected)
152+
{
153+
var actual = CertificatePinning.CertificatePinner.MatchesPattern(pattern, hostname);
154+
Assert.Equal(expected, actual);
155+
}
85156
}
86157
}

SecureHttpClient/CertificatePinning/CertificatePinner.cs

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
using System;
1+
using Microsoft.Extensions.Logging;
2+
using System;
23
using System.Collections.Concurrent;
4+
using System.Collections.Generic;
5+
using System.Linq;
36
using System.Security.Cryptography.X509Certificates;
4-
using Microsoft.Extensions.Logging;
57

68
namespace SecureHttpClient.CertificatePinning
79
{
@@ -19,18 +21,31 @@ public CertificatePinner(ILogger logger = null)
1921
public void AddPins(string hostname, string[] pins)
2022
{
2123
_logger?.LogDebug($"Add CertificatePinner: hostname:{hostname}, pins:{string.Join("|", pins)}");
24+
ValidatePattern(hostname);
2225
_pins[hostname] = pins; // Updates value if already existing
2326
}
2427

2528
public bool HasPin(string hostname)
2629
{
27-
return _pins.ContainsKey(hostname);
30+
if (string.IsNullOrEmpty(hostname))
31+
{
32+
throw new ArgumentException("hostname cannot be null or empty");
33+
}
34+
foreach (var (pattern, pins) in _pins)
35+
{
36+
if (MatchesPattern(pattern, hostname))
37+
{
38+
return true;
39+
}
40+
}
41+
return false;
2842
}
2943

3044
public bool Check(string hostname, X509Certificate2 certificate)
3145
{
32-
// Get pins
33-
if (!_pins.TryGetValue(hostname, out var pins))
46+
// Get matching pins
47+
var pins = GetMatchingPins(hostname);
48+
if (pins.Length == 0)
3449
{
3550
_logger?.LogDebug($"No certificate pin found for {hostname}");
3651
return true;
@@ -51,5 +66,72 @@ public bool Check(string hostname, X509Certificate2 certificate)
5166
}
5267
return match;
5368
}
69+
70+
internal static void ValidatePattern(string pattern)
71+
{
72+
if (string.IsNullOrEmpty(pattern))
73+
{
74+
throw new ArgumentException("Pattern cannot be null or empty");
75+
}
76+
if (pattern.StartsWith("*."))
77+
{
78+
if (pattern.IndexOf('*', 1) != -1)
79+
{
80+
throw new ArgumentException($"Unexpected pattern: {pattern}");
81+
}
82+
}
83+
else if (pattern.StartsWith("**."))
84+
{
85+
if (pattern.IndexOf('*', 2) != -1)
86+
{
87+
throw new ArgumentException($"Unexpected pattern: {pattern}");
88+
}
89+
}
90+
else if (pattern.Contains('*'))
91+
{
92+
throw new ArgumentException($"Unexpected pattern: {pattern}");
93+
}
94+
}
95+
96+
97+
private string[] GetMatchingPins(string hostname)
98+
{
99+
if (string.IsNullOrEmpty(hostname))
100+
{
101+
throw new ArgumentException("hostname cannot be null or empty");
102+
}
103+
var matchedPins = new HashSet<string>();
104+
foreach (var (pattern, pins) in _pins)
105+
{
106+
if (MatchesPattern(pattern, hostname))
107+
{
108+
foreach (var pin in pins)
109+
{
110+
matchedPins.Add(pin);
111+
}
112+
}
113+
}
114+
return matchedPins.ToArray();
115+
}
116+
117+
internal static bool MatchesPattern(string pattern, string hostname)
118+
{
119+
if (pattern.StartsWith("**."))
120+
{
121+
var suffix = pattern[3..];
122+
return hostname == suffix || hostname.EndsWith("." + suffix);
123+
}
124+
if (pattern.StartsWith("*."))
125+
{
126+
var suffix = pattern[2..];
127+
if (!hostname.EndsWith("." + suffix))
128+
{
129+
return false;
130+
}
131+
var prefix = hostname[..(hostname.Length - suffix.Length - 1)];
132+
return !prefix.Contains('.');
133+
}
134+
return hostname == pattern;
135+
}
54136
}
55137
}

SecureHttpClient/Platforms/Android/SecureHttpClientHandler.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public SecureHttpClientHandler(ILogger<Abstractions.ISecureHttpClientHandler> lo
5454
public virtual void AddCertificatePinner(string hostname, string[] pins)
5555
{
5656
_logger?.LogDebug($"Add CertificatePinner: hostname:{hostname}, pins:{string.Join("|", pins)}");
57+
CertificatePinning.CertificatePinner.ValidatePattern(hostname); // will throw c# exception instead of java exception if pattern is invalid
5758
_certificatePinnerBuilder.Value.Add(hostname, pins);
5859
}
5960

0 commit comments

Comments
 (0)