Thursday, April 11, 2019

Securing an Endpoint with an API Key

I've used API keys to access various 3rd party APIs in the past, but until recently I hadn't ever secured my own endpoint with an API key. Don't get me wrong, I've secured my endpoints in the past, just usually through the use of the [Authorize] attribute in .NET. Recently I encountered a situation where we needed to call an endpoint from an integration (SSIS) package that didn't have the opportunity to log in first. It quickly became obvious to us that the solution was to secure that particular endpoint in a different way. And here we are. This wasn't a difficult process, but it was kind of frustrating and it was actually pretty fun to implement so I wanted to keep track of the solution. Also, it's been a really long time since I posted so I figured it was time.

There are two parts to the solution. The first part - the caller (in our case an SSIS package, but it could be anything) - builds the API key and sends the request. The second part - the receiver (in our case a Web API endpoint) - receives the request and checks it for validity. There are a few things we considered when developing this solution.
  1. We needed to only allow requests from specific callers
  2. We needed to protect against replay attacks
  3. We needed to convey additional information in the request
After doing some research we came across Hash-based Message Authentication Code (HMAC) Authentication. It's basically a method for guaranteeing the information in the request via the use of an authentication header. (For more information on HMAC there's a pretty good blog post here.) There may be libraries out there to do this for us, but we couldn't find one we liked so we decided to roll our own.

Our HMAC Authentication header is going to have four parts to it: the ID of the caller, a time stamp, a random nonce value (a GUID), and the hashed (MD5), serialized (into JSON) content that we're sending to the server. The "content" here is the additional information we need to convey that I mentioned above.

The Caller

The first thing we do when creating the request is generate our content. In our particular case we're going to serialize, hash, and encode the content so that it can be included in the query string.
   1:  public string GenerateContent(string email, int id, string name)
   2:  {
   3:      var content = new SomeContent
   4:      {
   5:          Email = email,
   6:          Id = id,
   7:          Name = name
   8:      };
   9:  
   10:     var json = JsonConvert.SerializeObject(content);
   11: 
   12:     string contentAsBase64String;
   13:     using (var md5 = MD5.Create())
   14:     {
   15:         var encodedContent = Encoding.ASCII.GetBytes(json);
   16:         var md5Hash = md5.ComputeHash(encodedContent);
   17:         contentAsBase64String = Convert.ToBase64String(md5Hash);
   18:     }
   19: 
   20:     return contentAsBase64String;
   21: }


Now that we have the hashed, encoded contents that we're going to send to the endpoint we can build the full hmac header value. That's pretty easy with string interpolation.
   1:  var applicationId = "unicorns-and-puppies";
   2:  var timeStamp = DateTime.UtcNow;
   3:  var nonce = Guid.NewGuid();
   4:  var authenticationKey = $"{applicationId};{timeStamp.ToString("s")};{nonce};{contentAsBase64String}";


At this point we're going to create the request and add everything in as an authorization header. I'm including all of that code here, even though the mechanism for creating the request and adding the header can vary depending on which version of .Net you're using and any 3rd party libraries you might include.
   1:  var client = new HttpClient();
   2:  
   3:  var secretAsBase64 = Convert.FromBase64String("Some Randomly Generated String");
   4:  string hashedAuthenticationKey;
   5:  using (var hmac = HMACSHA512(secretAsBase64))
   6:  {
   7:      var authenticationKeyBytes = Encoding.UTF8.GetBytes(authenticationKey);
   8:      var hashedAuthenticationKey = hmac.ComputeHash(authenticationKeyBytes);
   9:      hashedAuthenticationKeyAsBase64String = Convert.ToBase64String(hashedAuthenticationKey);
   10: }
   11: 
   12: client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("hmac", authenticationKey);


We've added the header and now we want to create our body. What we're doing here is passing in the body with the content we want to pass in, and with the API key that we created just above. When the API receives the request we'll rebuild the API key and compare it to what's in the body here.

   1:  var body = new SomeRequest;
   2:  {
   3:      ApiKey = hashedAuthenticationKeyAsBase64String,
   4:      Content = json
   5:  };


And finally we can send the request.
   1:  var httpContent = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
   2:  
   3:  client.PostAsync("https://remote-endpoint.com/api/endpoint", httpContent);

The Endpoint

Now that we've sent the request, we have to receive it and validate it. When a .Net API receives a request there are different ways we can retrieve the data from the request. One common way is to use the FromBody and FromQuery attributes in the call signature. There's another attribute we're going to use to get the header, called FromHeader.

We'll get the body and the authorization header separately, validate the header pieces individually, then build the expected key the same way we did in the calling system and compare the expected key to the actual key to make sure they match.

   1:  public IActionResult Send([FromBody]SomeRequest something, [FromHeader(Name = "authorization")] string auth)
   2:  {
   3:      // check whether an authorization header exists
   4:  
   5:      var authorizationHeaderParts = authorizationHeader.Split(' ');
   6:      if (!authorizationHeader[0].Equals("hmac", StringComparison.OrdinalIgnoreCase))
   7:      {
   8:          // invalid request
   9:      }
   10: 
   11:     var authorizationPieces = authorizationHeaderParts[1].Split(';');
   12: 
   13:     if (authorizationPieces.Length != 4)
   14:     {
   15:         // invalid request
   16:     }
   17: 
   18:     // check whether authorizationPieces[0] is a valid application sending the request
   19: 
   20:     if ((DateTime.UtcNow - Convert.ToDateTime(authorizationPieces[1])).Seconds > 10)// we want the request to have been created less than 10 seconds ago to protect against replay attacks, but this length is arbitrary and could be lengthened or shortened based on need
   21:     {
   22:         // invalid request
   23:     }
   24: 
   25:     var applicationId = authorizationPieces[0];
   26:     var timestamp = authorizationPieces[1];
   27:     var nonce = authorizationPieces[2];
   28:     string contentAsBase64String;
   29:     using (var md5 = MD5.Create())
   30:     {
   31:         var content = Encoding.ASCII.GetBytes(something.Content);
   32:         var md5Hash = md5.ComputeHash(content);
   33:        contentAsBase64String = Convert.ToBase64String(md5Hash);
   34:     }
   35: 
   36:     if (!contentAsBase64String.Equals(authorizationPieces[3]))
   37:     {
   38:         // invalid request
   39:     }
   40: 
   41:     var authenticationKey = $"{applicationId};{timestamp};{nonce};{contentAsBase64String}";
   42: 
   43:     var secretAsBase64 = Convert.FromBase64String("Some Randomly Generated String");
   44: 
   45:     string hashedAuthenticationKeyAsBase64String;
   46:     using (var hmac = new HMACSHA512(secretAsBase64))
   47:     {
   48:         var authenticationKeyBytes = Encoding.UTF8.GetBytes(authenticationKey);
   49:         hashedAuthenticationKeyAsBase64String = Convert.ToBase64String(hashedAuthenticationKey);
   50:     }
   51:     if (!hashedAuthenticationKeyAsBase64String.Equals(something.ApiKey))
   52:     {
   53:         // invalid request
   54:     }


That's it! We created a request protected with an API key and then accepted the request and validated the API key. After looking this over again so I could post this entry I realize that it's probably more convoluted than it really needs to be. I think in the future if I have to do this again I'll use this code as the starting point, but definitely see where I can streamline it.

No comments:

Post a Comment