Last active
August 29, 2015 14:19
-
-
Save rdev5/6da1a2f46460a3507dde to your computer and use it in GitHub Desktop.
A utility class for establishing SSL connection with remote web service and returning response in SecureString object. Unit tests included.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Specialized; | |
using System.ComponentModel; | |
using System.Linq; | |
using System.Net.Security; | |
using System.Net.Sockets; | |
using System.Runtime.InteropServices; | |
using System.Security; | |
using System.Security.Cryptography.X509Certificates; | |
using System.Text; | |
using System.Web; | |
namespace SecureClient | |
{ | |
/// <summary> | |
/// A utility class for establishing SSL connection with remote web service and returning response in SecureString object. | |
/// </summary> | |
/// <author>Matt Borja</author> | |
public sealed class SecureClient | |
{ | |
private const string _httpVersion = "HTTP/1.1"; | |
private const string _expectedUriScheme = "https"; | |
private const int _maxSecureStringLength = 65536; | |
/// <summary> | |
/// Exposes helper methods for encoding HTTP headers | |
/// | |
/// Source: https://github.com/mono/mono/blob/master/mcs/class/System.Web/System.Web.Util/HttpEncoder.cs#L94-L130 | |
/// </summary> | |
private static void StringBuilderAppend(string s, ref StringBuilder sb) | |
{ | |
if (sb == null) | |
sb = new StringBuilder(s); | |
else | |
sb.Append(s); | |
} | |
public static string EncodeHeaderString(string input) | |
{ | |
StringBuilder sb = null; | |
foreach (char ch in input) | |
{ | |
if ((ch < 32 && ch != 9) || ch == 127) | |
StringBuilderAppend(String.Format("%{0:x2}", (int)ch), ref sb); | |
} | |
if (sb != null) | |
return sb.ToString(); | |
return input; | |
} | |
public static NameValueCollection EncodeHeaders(NameValueCollection headers) | |
{ | |
var encodedHeaders = new NameValueCollection(); | |
foreach (var k in headers.AllKeys) | |
{ | |
var encodedHeaderName = String.IsNullOrEmpty(k) ? k : EncodeHeaderString(k); | |
encodedHeaders[encodedHeaderName] = String.IsNullOrEmpty(headers[k]) ? headers[k] : EncodeHeaderString(headers[k]); | |
} | |
return encodedHeaders; | |
} | |
/// <summary> | |
/// Remote certificate validation callback | |
/// </summary> | |
/// <param name="sender"></param> | |
/// <param name="certificate"></param> | |
/// <param name="chain"></param> | |
/// <param name="sslPolicyErrors"></param> | |
/// <returns></returns> | |
private static bool ValidateServerCertificate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) | |
{ | |
return sslPolicyErrors == SslPolicyErrors.None; | |
} | |
/// <summary> | |
/// Helper function for creating rawRequest from NameValueCollections | |
/// </summary> | |
/// <param name="uri"></param> | |
/// <param name="body"></param> | |
/// <param name="headers"></param> | |
/// <param name="method"></param> | |
/// <returns></returns> | |
public static string CreateRawRequest(Uri uri, NameValueCollection body = null, NameValueCollection headers = null, string method = null) | |
{ | |
if (uri == null) throw new ArgumentNullException("uri"); | |
var rawRequest = String.Empty; | |
// Set default request method | |
method = String.IsNullOrEmpty(method) ? "GET" : method; | |
// Construct HTTP verb with version (i.e. GET /page?params=... HTTP/1.1) | |
rawRequest = String.Format("{0} {1} {2}", method, uri.PathAndQuery, _httpVersion) + Environment.NewLine + rawRequest; | |
// Construct headers | |
if (headers != null) | |
{ | |
// Set default Host header | |
if (!headers.AllKeys.Contains("Host")) | |
{ | |
headers["Host"] = uri.Authority; | |
} | |
// Encode headers | |
headers = EncodeHeaders(headers); | |
rawRequest += String.Join(Environment.NewLine, headers.AllKeys.Select(k => k + ": " + headers[k])); | |
rawRequest += Environment.NewLine; | |
} | |
// Construct parameters | |
if (body != null) | |
{ | |
rawRequest += String.Join("&", body.AllKeys.Select(k => k + "=" + HttpUtility.UrlEncode(body[k]))); | |
rawRequest += Environment.NewLine; | |
} | |
return rawRequest.Trim(); | |
} | |
/// <summary> | |
/// Uses SslStream to marshal TcpClient response buffer into SecureString object one byte at a time. | |
/// | |
/// Considerations when implementing: | |
/// - Request should contain "Connection: close" HTTP header or connection will remain opened (i.e. keep-alive) and block. | |
/// - ReadByte will block the application if no bytes are received so a default Read/WriteTimeout of 1000ms is provided. | |
/// </summary> | |
/// <param name="uri">Request Uri</param> | |
/// <param name="rawRequest" rel="POST"> | |
/// --- For POST requests --- | |
/// | |
/// POST /bin/login HTTP/1.1 | |
/// Host: www.example.com | |
/// Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 | |
/// Pragma: no-cache | |
/// Content-Type: application/x-www-form-urlencoded | |
/// User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36 | |
/// Content-Length: 37 | |
/// Connection: close | |
/// Cache-Control: no-cache | |
/// User=Peter+Lee&pw=123456&action=login | |
/// | |
/// --- For GET requests --- | |
/// | |
/// GET /index.html?param1=value¶m2=value HTTP/1.1 | |
/// Host: www.example.com | |
/// Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 | |
/// Pragma: no-cache | |
/// User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36 | |
/// Cache-Control: no-cache | |
/// Connection: close | |
/// | |
/// </param> | |
/// <param name="timeout">Maximum amount of time in milliseconds a single ReadByte() or Write() operation can block the caller (default: 1000ms)</param> | |
/// <param name="readBytesAtOnce"> | |
/// This is an optional performance optimization mode (specified as total number of bytes to read from SslStream) for | |
/// constructing the entire SecureString at once at the expense of momentarily storing more plaintext in insecure memory. | |
/// | |
/// If set to 1, all bytes in SslStream will be marshalled into SecureString object one byte at a time. | |
/// If set to any number greater than 1, this will become the total number of bytes read from SslStream at once. | |
/// | |
/// Example benchmarks using Uri("https://www.google.com/") | |
/// - Time to initialize SecureString with 65536 chars using unsafe context: 00:00:00.20 | |
/// - Time to individually marshal 65536 chars into SecureString one at a time: 00:00:52.51 | |
/// | |
/// Caution: | |
/// - The larger readBytesAtOnce is, the more plaintext in insecure memory will be stored until SecureString initialization has been completed. | |
/// - All remaining bytes in stream will be truncated after the specified number of bytes have been read. | |
/// - Maximum size of readBytesAtOnce is the maximum size of SecureString (65536) | |
/// </param> | |
/// <returns>SecureString containing HTTP response</returns> | |
public static SecureString SecureHttpRequest(Uri uri, string rawRequest = null, uint timeout = 1000, uint readBytesAtOnce = 1) | |
{ | |
if (uri == null) throw new ArgumentNullException("uri"); | |
if (uri.Scheme != _expectedUriScheme) throw new WarningException("URI does not match expected scheme and may fail to establish SSL connection.", new UriFormatException(String.Format("Expected URI scheme was <{0}> but got <{1}> instead.", _expectedUriScheme, uri.Scheme))); | |
if (readBytesAtOnce < 1) throw new ArgumentOutOfRangeException("readBytesAtOnce"); | |
if (readBytesAtOnce > _maxSecureStringLength) throw new ArgumentOutOfRangeException("readBytesAtOnce"); | |
// Construct default GET request if none provided | |
if (String.IsNullOrEmpty(rawRequest)) | |
{ | |
var headers = new NameValueCollection(); | |
headers["Host"] = uri.Authority; | |
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"; | |
headers["Pragma"] = "no-cache"; | |
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36"; | |
headers["Cache-Control"] = "no-cache"; | |
headers["Connection"] = "close"; | |
rawRequest = CreateRawRequest(uri, null, headers); | |
} | |
var secureResponse = new SecureString(); | |
using (var client = new TcpClient(uri.Host, uri.Port)) | |
{ | |
using (var sslStream = new SslStream(client.GetStream(), false, ValidateServerCertificate, null)) | |
{ | |
try | |
{ | |
// Set timeouts | |
sslStream.WriteTimeout = (int) timeout; | |
sslStream.ReadTimeout = (int) timeout; | |
// Authenticate | |
sslStream.AuthenticateAsClient(uri.Host); | |
// Send (request must be terminated with at least two newline characters) | |
sslStream.Write(Encoding.UTF8.GetBytes(rawRequest + Environment.NewLine + Environment.NewLine)); | |
sslStream.Flush(); | |
// Receive bytes at once (if set) | |
// Time to append 256 chars to SecureString: 00:00:00.19 | |
if (readBytesAtOnce != 1) | |
{ | |
// Pin chunk in memory | |
var chunk = new byte[readBytesAtOnce]; | |
var chunkHandler = GCHandle.Alloc(chunk, GCHandleType.Pinned); | |
var chars = new char[chunk.Length]; | |
var charHandler = GCHandle.Alloc(chars, GCHandleType.Pinned); | |
try | |
{ | |
// Read all bytes at once | |
sslStream.Read(chunk, 0, (int) readBytesAtOnce); | |
// Uses decrement optimization for converting byte[] to char[] | |
for (var i = chunk.Length - 1; i >= 0; --i) | |
{ | |
chars[i] = Convert.ToChar(chunk[i]); | |
// Zero byte in source | |
chunk[i] = 0; | |
} | |
unsafe | |
{ | |
// Create byte pointer to chunk and re-cast as char pointer | |
fixed (char* charPtr = chars) | |
{ | |
secureResponse = new SecureString(charPtr, chunk.Length); | |
} | |
} | |
} | |
finally | |
{ | |
// Scrub and free handler | |
Array.Clear(chunk, 0, chunk.Length); | |
chunkHandler.Free(); | |
Array.Clear(chars, 0, chunk.Length); | |
charHandler.Free(); | |
} | |
} | |
// Otherwise marshal bytes one at a time | |
// Time to append 65536 chars to SecureString: 00:00:52.51 | |
else | |
{ | |
// Pin byte in memory | |
var b = -1; | |
var byteHandler = GCHandle.Alloc(b, GCHandleType.Pinned); | |
try | |
{ | |
do | |
{ | |
b = sslStream.ReadByte(); | |
if (b == -1) break; | |
secureResponse.AppendChar(Convert.ToChar(b)); | |
} while (sslStream.CanRead && b != -1); | |
} | |
catch (ArgumentOutOfRangeException) | |
{ | |
// SecureString has a maximum length of 65535 characters (unable to determine length from SslStream) | |
} | |
finally | |
{ | |
// Scrub byte and free handler | |
b = -1; | |
byteHandler.Free(); | |
} | |
} | |
} | |
// Ensure cleanup | |
finally | |
{ | |
// Make secureMessage read-only | |
secureResponse.MakeReadOnly(); | |
// Explicitly close streams | |
sslStream.Close(); | |
client.Close(); | |
} | |
return secureResponse; | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Specialized; | |
using System.ComponentModel; | |
using System.Runtime.InteropServices; | |
using System.Text; | |
using Microsoft.VisualStudio.TestTools.UnitTesting; | |
namespace SecureClient.Tests | |
{ | |
[TestClass] | |
public class SecureClientTest | |
{ | |
#region CreateRawRequest | |
[TestMethod] | |
[ExpectedException(typeof(ArgumentNullException))] | |
public void CreateRawRequestThrowsExceptionForNullUri() | |
{ | |
SecureClient.CreateRawRequest(null); | |
} | |
[TestMethod] | |
public void CreateRawRequestUsesDefaultUriAuthority() | |
{ | |
// Pre-calculated expectation | |
var expectedResult = | |
Encoding.UTF8.GetString( | |
Convert.FromBase64String("R0VUIC8gSFRUUC8xLjENCkFjY2VwdDogdGV4dC9odG1sLGFwcGxpY2F0aW9uL3hodG1sK3htbCxhcHBsaWNhdGlvbi94bWw7cT0wLjksaW1hZ2Uvd2VicCwqLyo7cT0wLjgNClByYWdtYTogbm8tY2FjaGUNClVzZXItQWdlbnQ6IE1vemlsbGEvNS4wIChXaW5kb3dzIE5UIDYuMTsgV09XNjQpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIENocm9tZS80MS4wLjIyNzIuMTE4IFNhZmFyaS81MzcuMzYNCkNhY2hlLUNvbnRyb2w6IG5vLWNhY2hlDQpDb25uZWN0aW9uOiBjbG9zZQ0KSG9zdDogd3d3LmV4YW1wbGUuY29t")); | |
// Arrange | |
var uri = new Uri("https://www.example.com/"); | |
var headers = new NameValueCollection(); | |
// headers["Host"] = uri.Authority | |
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"; | |
headers["Pragma"] = "no-cache"; | |
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36"; | |
headers["Cache-Control"] = "no-cache"; | |
headers["Connection"] = "close"; | |
// Act | |
var rawRequest = SecureClient.CreateRawRequest(uri, null, headers); | |
// Assert | |
Assert.AreEqual(expectedResult, rawRequest); | |
} | |
[TestMethod] | |
public void CreateRawRequestReturnsExpectedResult() | |
{ | |
// Pre-calculated expectation | |
var expectedResult = | |
Encoding.UTF8.GetString( | |
Convert.FromBase64String("R0VUIC9zZWFyY2g/cT1lbGxpcHRpYytjdXJ2ZStjcnlwdG9ncmFwaHkgSFRUUC8xLjENCkhvc3Q6IHd3dy5nb29nbGUuY29tDQpBY2NlcHQ6IHRleHQvaHRtbCxhcHBsaWNhdGlvbi94aHRtbCt4bWwsYXBwbGljYXRpb24veG1sO3E9MC45LGltYWdlL3dlYnAsKi8qO3E9MC44DQpQcmFnbWE6IG5vLWNhY2hlDQpVc2VyLUFnZW50OiBNb3ppbGxhLzUuMCAoV2luZG93cyBOVCA2LjE7IFdPVzY0KSBBcHBsZVdlYktpdC81MzcuMzYgKEtIVE1MLCBsaWtlIEdlY2tvKSBDaHJvbWUvNDEuMC4yMjcyLjExOCBTYWZhcmkvNTM3LjM2DQpDYWNoZS1Db250cm9sOiBuby1jYWNoZQ0KQ29ubmVjdGlvbjogY2xvc2UNCnBhcmFtQT1IZWxsbyZwYXJhbUI9V29ybGQmcGFyYW1DPTE=")); | |
// Arrange | |
var uri = new Uri("https://www.google.com/search?q=elliptic+curve+cryptography"); | |
var body = new NameValueCollection(); | |
body["paramA"] = "Hello"; | |
body["paramB"] = "World"; | |
body["paramC"] = "1"; | |
var headers = new NameValueCollection(); | |
headers["Host"] = "www.google.com"; | |
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"; | |
headers["Pragma"] = "no-cache"; | |
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36"; | |
headers["Cache-Control"] = "no-cache"; | |
headers["Connection"] = "close"; | |
// Act | |
var rawRequest = SecureClient.CreateRawRequest(uri, body, headers); | |
// Assert | |
Assert.AreEqual(expectedResult, rawRequest); | |
} | |
#endregion | |
#region SecureHttpRequest | |
[TestMethod] | |
[ExpectedException(typeof(ArgumentNullException))] | |
public void SecureHttpRequestThrowsExceptionForNullUri() | |
{ | |
SecureClient.SecureHttpRequest(null); | |
} | |
[TestMethod] | |
[ExpectedException(typeof(WarningException))] | |
public void SecureHttpRequestThrowsWarningForInvalidUriScheme() | |
{ | |
var uri = new Uri("http://www.example.com/"); | |
SecureClient.SecureHttpRequest(uri); | |
} | |
[TestMethod] | |
public void SecureHttpRequestUsesDefaultUri() | |
{ | |
var uri = new Uri("https://www.example.com/"); | |
var secureResponse = SecureClient.SecureHttpRequest(uri); | |
Assert.AreNotEqual(0, secureResponse.Length); | |
} | |
[TestMethod] | |
public void SecureHttpRequestReadsBytesAtOnce() | |
{ | |
var uri = new Uri("https://www.example.com/"); | |
var secureResponse = SecureClient.SecureHttpRequest(uri, null, 1000, 65536); | |
Assert.AreEqual(secureResponse.Length, 65536); | |
} | |
[TestMethod] | |
[ExpectedException(typeof(ArgumentOutOfRangeException))] | |
public void SecureHttpRequestThrowsExceptionForZeroSize() | |
{ | |
var uri = new Uri("https://www.example.com/"); | |
SecureClient.SecureHttpRequest(uri, null, 1000, 0); | |
} | |
[TestMethod] | |
[ExpectedException(typeof(ArgumentOutOfRangeException))] | |
public void SecureHttpRequestThrowsExceptionForUnsupportedSize() | |
{ | |
var uri = new Uri("https://www.example.com/"); | |
SecureClient.SecureHttpRequest(uri, null, 1000, 65537); | |
} | |
[TestMethod] | |
public void SecureHttpRequestReturnsReadableSecureString() | |
{ | |
var expectedResult = "HTTP/1.1 200"; | |
// Arrange | |
var insecureStringPtr = IntPtr.Zero; | |
var insecureString = ""; | |
var gcHandler = GCHandle.Alloc(insecureString, GCHandleType.Pinned); | |
var uri = new Uri("https://www.example.com/"); | |
// Act | |
var secureResponse = SecureClient.SecureHttpRequest(uri, null, 1000, (uint)expectedResult.Length); | |
// Assert | |
try | |
{ | |
insecureStringPtr = Marshal.SecureStringToGlobalAllocUnicode(secureResponse); | |
insecureString = Marshal.PtrToStringUni(insecureStringPtr); | |
Assert.IsNotNull(insecureString); | |
Assert.AreEqual(expectedResult, insecureString); | |
} | |
finally | |
{ | |
insecureString = null; | |
gcHandler.Free(); | |
Marshal.ZeroFreeGlobalAllocUnicode(insecureStringPtr); | |
} | |
} | |
#endregion | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment