# Security Features

# Endpoint Validation

The validation step enforces transport layer security by ensuring the endpoint is HTTPS with a valid certificate. Additionally, there is an optional feature, called the Token Challenge, that can be enabled if the client opts in. Please refer to Token Challenge section for details.

# API Key

  1. x-api-key is a custom HTTP header that can be used to secure APIs. This header is a type of API key that is passed with each API request, and it is used to authenticate and authorize the user or application that is making the request.
  2. The API key is created by generating a GUID string without the dashes
  3. The API key is set when creating/updating an endpoint
  4. The x-api-key header will be included in all outgoing requests and cannot be used as a custom header for webhooks as it will already be included

# HMAC Authentication

We have this feature so that the customer can verify the event notification coming from Cornerstone and is not tampered with. This occurs on every callback to the client endpoint.

In the callback, we compute the hash and provide it in the Authorization header as below. Signature is computed from HTTP Request Method, Request URI, Secret, and Header Values.

The hash computation primarily involves the following elements:

  1. Challenge header (x-content-sha256)
  2. Date
  3. Secret (defined in endpoint configuration)
  4. Custom header values (if defined in webhook configuration)

Customer will need to recompute the hash and compare it to ensure it matches and no tampering has occurred. The Challenge Header, Date, and Custom Header are part of the standard HTTP headers, along with the Authorization header, which contains the actual hash. The Secret is not sent in the payload and is maintained in both systems.

A snapshot of the above headers is shown as below:

Authorization Headers

Secret Key is defined in the endpoint configuration as below:

Secret Token Generation

Here's a pseudocode example that customer can implement at their end:

// Parse Authorization header and check if Scheme is HMAC-SHA256 
if (AuthenticationHeaderValue.TryParse((string)Request.Headers["Authorization"], out AuthenticationHeaderValue authorization)) 
{ 
    if (authorization.Scheme == "HMAC-SHA256") 
    { 
        // Split Authorization to get values of signedHeaders and Signature 
        // Get corresponding values of Signed Headers 
        // Find new Signature using header values and secretKey (mutually shared) 
        var newSignature = _signatureProvider.ComputeHMACSHA256Hash(httpMethod, url, secretKey, headerValues);  
 
        // Validate both signatures to validate 
        return newSignature == signature; 
    } 
}

Sample .NET Implementation for ComputeHMACSHA256Hash

/// <summary> 
/// Computes HMAC-SHA256 signature based on 
/// https://learn.microsoft.com/en-us/azure/azure-app-configuration/rest-api-authentication-hmac 
/// </summary> 
/// <param name="requestUri">request URI as request is made</param> 
/// <param name="base64EncodedSecret">Base64 encoded API secret OR just a string that must not look like Base64 encoded token (for testing and special cases)</param> 
/// <param name="signedHeaderValues">one or more header values to sign</param> 
/// <param name="httpMethod">request <see cref="T:System.Net.Http.HttpMethod" /></param> 
/// <returns>Base64 encoded signature string</returns> 
public static string GetHmacSha256Base64Hash( 
   this Uri requestUri, 
   HttpMethod httpMethod, 
   string base64EncodedSecret, 
   IEnumerable<string> signedHeaderValues) 
{ 
   string s = string.Join('\n', new string[3] 
   { 
     httpMethod.ToString().ToUpper(), 
     requestUri.PathAndQuery.ToLowerInvariant(), 
     string.Join(";", signedHeaderValues) 
   });
 
   byte[] key; 
   try 
   { 
     key = Convert.FromBase64String(base64EncodedSecret); 
   } 
   catch (FormatException ex) 
   { 
     key = Encoding.UTF8.GetBytes(base64EncodedSecret); 
   }
 
   using (HMACSHA256 hmacshA256 = new HMACSHA256(key)) 
     return Convert.ToBase64String(hmacshA256.ComputeHash(Encoding.ASCII.GetBytes(s))); 
}

# Custom Headers

  1. Webhooks can be configured to provide up to four key value pairs to be used to validate event messages being received from the webhook
  2. Custom header key value pairs are included as part of the request headers
  3. Custom header keys must start with "x-", can only contain alphanumeric characters or dashes, and have max length of 25
    • Regex: ^[xX]-[a-zA-Z0-9-]{0,25}$
    • Example: "x-csod-authentication"
  4. Custom header values must contain alphanumeric characters with some allowed special characters and have a max length of 1000
    • Regex: ^[a-zA-Z0-9-_,.]{0,1000}$

# Token Challenge Verification

# Goal

The goal of Token Challenge feature is to ensure that we can verify ownership and prevent man-in-the-middle attacks (MitM). Once the feature is enabled, customer can not enable an endpoint until it has been verified.

Verification will happen when:

  • New Callback is created
  • Existing Callback's endpoint is updated

# How does it work?

  1. We send a POST request to the configured endpoint with the following headers:
  • x-challenge-sha256

  • x-api-key: Details can be found under the API KEY section

    Following is a sample:

    curl -X POST \
    http://testportal.com/csod/webhook \
    -H 'Content-Type: application/json' \
    -H 'cache-control: no-cache' \
    -H 'x-challenge-sha256: dbq2kjiql2c4' \
    -H 'x-api-key: fecf1a1431d24311b0bfbc870f8c6f77' \
    
  1. The Callback owner should compute a signature using the Callback's secret as defined in the endpoint configuration, HTTP request method, request URI, and the challenge token. To compute the signature, the Callback owner should concatenate the HTTP request method, request URI's path and query component (ie, /webhook?a=1), and challenge token into a string and generate a base64 encoded HMAC SHA-256 hash using the secret key.

    Following is a sample pseudo code to illustrate the hashing process:

    string stringToSign = httpMethod + requestUri + challengeToken
    string computedHash = generateHMACSHA256(stringToSign, secretKey)
    
  2. The Callback owner should respond back with the the signature in the response headers. The response code should be HTTP 200. See example below for reference.

    HTTP 200 OK
    Content-Type: application/json;charset=UTF-8
    x-challenge-sha256: VDGoEZSWh5ANxFBL999m7RzJnjXvoO52c/0Wkg8Lzmw=
    
  3. On receiving the response, Cornerstone will calculate the signature using the HTTP request method, request URI, secret, and challenge token. If the signature we generate matches the signature sent in the response, the Callback is marked as 'verified'. If not, then the response code, response body, and the error are displayed to the user in the UI.

# PGP Key Encryption

# Overview of PGP keys with Edge Webhooks

With webhooks, clients are able to provide an encryption key during webhook creation in order to encrypt the data flowing from webhooks to their designated endpoints. This is optional for a webhook but required for webhooks that emit employee events.

During webhook creation, an option will be available to add in an encryption key. Encryption Key Options

If no keys are available, you'll need to generate them. Please go to Admin > Tools > Edge > Imports and Feeds (this will redirect into Edge Import) > Key Management > Report Encryption Keys.

Create Key

On this page, you can create a key which in turn will then appear on the Encryption Key dropdown during webhook creation. Only keys which are not expired will be displayed on the webhook create/edit flyout and if the key ends up being expired, the webhook will not renew once configured.

The following is a code sample of how we are doing PGP decryption:

using Org.BouncyCastle.Bcpg.OpenPgp;

public static Stream GetPgpDecryptedData(this Stream encryptedData)
{
    return AddPgpDecryption(encryptedData, PgpPrivateKey, Passphrase);
}

private static Stream AddPgpDecryption(this Stream inputStream, string privateKey, string passPhrase)
{
    var sKey = ReadPrivateKeyNew(privateKey, passPhrase);

    var pgpF = new PgpObjectFactory(inputStream);
    var enc = (PgpEncryptedDataList)pgpF.NextPgpObject();
    var pbe = enc.GetEncryptedDataObjects().Cast<PgpPublicKeyEncryptedData>().First();

    return pbe.GetDataStream(sKey);
}

private static PgpPrivateKey ReadPrivateKeyNew(string privateKey, string passPhrase)
{
    using var stringStream = new MemoryStream(Encoding.UTF8.GetBytes(privateKey));
    using var decodedStream = PgpUtilities.GetDecoderStream(stringStream);

    var pgpSec = new PgpSecretKeyRingBundle(decodedStream);
    foreach (PgpSecretKeyRing keyRing in pgpSec.GetKeyRings())
    {
        foreach (PgpSecretKey key in keyRing.GetSecretKeys())
        {
            if (key.IsSigningKey)
            {
                return key.ExtractPrivateKey(passPhrase.ToCharArray());
            }
        }
    }
    throw new ArgumentException("Can't find signing key in key ring.");
}