# 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
- 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.
- The API key is created by generating a GUID string without the dashes
- The API key is set when creating/updating an endpoint
- 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:
- Challenge header (x-content-sha256)
- Date
- Secret (defined in endpoint configuration)
- 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:
Secret Key is defined in the endpoint configuration as below:
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
- Webhooks can be configured to provide up to four key value pairs to be used to validate event messages being received from the webhook
- Custom header key value pairs are included as part of the request headers
- 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"
- Regex:
- 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}$
- Regex:
# 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?
- 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' \
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)
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=
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.
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.
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 BCPgp = Org.BouncyCastle.Bcpg.OpenPgp;
/// <summary>
/// Decrypts PGP encrypted data using the default private key
/// </summary>
/// <param name="encryptedData">The encrypted data stream</param>
/// <returns>A stream containing the decrypted data</returns>
public static Stream GetPgpDecryptedData(this Stream encryptedData, bool addGpg)
{
if (addGpg)
{
AddGpgDecryption(encryptedData, PgpPrivateKey, Passphrase);
}
return AddPgpDecryption(encryptedData, PgpPrivateKey, Passphrase);
}
/// <summary>
/// Adds PGP decryption to a stream using the specified private key and passphrase
/// </summary>
/// <param name="inputStream">The encrypted input stream</param>
/// <param name="privateKey">The PGP private key in ASCII-armored format</param>
/// <param name="passPhrase">The passphrase for the private key</param>
/// <returns>A stream containing the decrypted data</returns>
private static void AddGpgDecryption(this Stream inputStream, string privateKey, string passPhrase)
{
try
{
// First, check if the input stream has content
if (inputStream.Length == 0)
{
throw new ArgumentException("Input stream is empty", nameof(inputStream));
}
// Ensure we're at the beginning of the stream
if (inputStream.CanSeek)
{
inputStream.Position = 0;
}
// Create a copy of the input stream to preserve the original
var inputStreamCopy = new MemoryStream();
inputStream.CopyTo(inputStreamCopy);
inputStreamCopy.Position = 0;
// Write the encrypted data to a file for manual decryption
string tempDir = Path.Combine(Path.GetTempPath(), "EdgeEndpointsService_PGP");
Directory.CreateDirectory(tempDir);
// Save files to a specific directory on disk for manual testing
string diskDir = @"C:\pgp_decrypt_files";
Directory.CreateDirectory(diskDir);
string encryptedFilePath = Path.Combine(diskDir, $"encrypted_{DateTime.Now:yyyyMMdd_HHmmss}.pgp");
string privateKeyPath = Path.Combine(diskDir, "private_key.asc");
string passphraseFilePath = Path.Combine(diskDir, "passphrase.txt");
string instructionsPath = Path.Combine(diskDir, "decryption_instructions.txt");
// Write the encrypted data to a file
using (var fileStream = new FileStream(encryptedFilePath, FileMode.Create, FileAccess.Write))
{
inputStreamCopy.CopyTo(fileStream);
}
// Write the private key to a file
File.WriteAllText(privateKeyPath, privateKey);
// Write the passphrase to a file
File.WriteAllText(passphraseFilePath, passPhrase);
// Write instructions for manual decryption
string instructions = @"To manually decrypt the PGP encrypted file, follow these steps:
1. Install GPG if not already installed:
- Windows: https://www.gnupg.org/download/
- Mac: brew install gnupg
- Linux: apt-get install gnupg or yum install gnupg
2. Import the private key:
gpg --import private_key.asc
3. Decrypt the file:
gpg --batch --yes --passphrase-file passphrase.txt --pinentry-mode loopback --decrypt --output decrypted_file.txt encrypted_*.pgp
The decrypted file will be saved as 'decrypted_file.txt' in the current directory.";
File.WriteAllText(instructionsPath, instructions);
// Log the location of the files
Console.WriteLine($"PGP encrypted data saved to: {encryptedFilePath}");
Console.WriteLine($"Decryption instructions saved to: {instructionsPath}");
}
catch (Exception ex)
{
throw new Exception($"PGP decryption failed: {ex.Message}", ex);
}
}
/// <summary>
/// Adds PGP decryption to a stream using the specified private key and passphrase
/// </summary>
/// <param name="inputStream">The encrypted input stream</param>
/// <param name="privateKey">The PGP private key in ASCII-armored format</param>
/// <param name="passPhrase">The passphrase for the private key</param>
/// <returns>A stream containing the decrypted data</returns>
private static Stream AddPgpDecryption(this Stream inputStream, string privateKey, string passPhrase)
{
try
{
// First, check if the input stream has content
if (inputStream.Length == 0)
{
throw new ArgumentException("Input stream is empty", nameof(inputStream));
}
// Ensure we're at the beginning of the stream
if (inputStream.CanSeek)
{
inputStream.Position = 0;
}
// Create a memory stream for the decrypted output
var outputStream = new MemoryStream();
// Create a stream for the private key
using (var privateKeyStream = new MemoryStream(Encoding.UTF8.GetBytes(privateKey)))
{
// Get the private key
var pgpPrivateKey = ReadPrivateKey(privateKeyStream, passPhrase.ToCharArray());
// Create a PGP object factory
// Instead of:
// var pgpFact = new PgpObjectFactory(PgpUtilities.GetDecoderStream(inputStream));
// Use:
var pgpFact = new BCPgp.PgpObjectFactory(BCPgp.PgpUtilities.GetDecoderStream(inputStream));
// The first object might be a PGP marker packet
var pgpObject = pgpFact.NextPgpObject();
// Skip marker packet if present
if (pgpObject is PgpMarker)
{
pgpObject = pgpFact.NextPgpObject();
}
// Handle encrypted data
if (pgpObject is PgpEncryptedDataList encryptedDataList)
{
// Find the secret key that matches one of the encrypted data packets
PgpPrivateKey decryptionKey = null;
PgpPublicKeyEncryptedData encryptedData = null;
// Try each encrypted data packet
foreach (PgpPublicKeyEncryptedData pkEncData in encryptedDataList.GetEncryptedDataObjects())
{
// If we have the private key for this encrypted data, use it
if (pgpPrivateKey.KeyId == pkEncData.KeyId)
{
decryptionKey = pgpPrivateKey;
encryptedData = pkEncData;
break;
}
}
if (decryptionKey == null)
{
throw new ArgumentException("Secret key for message not found.");
}
// Get the decrypted data stream
var clear = encryptedData.GetDataStream(decryptionKey);
// Create a PGP object factory for the decrypted data
var plainFact = new PgpObjectFactory(clear);
// Process the decrypted data
var message = plainFact.NextPgpObject();
if (message is PgpCompressedData compressedData)
{
// Handle compressed data
var compressedDataStream = compressedData.GetDataStream();
var compressedFactory = new PgpObjectFactory(compressedDataStream);
message = compressedFactory.NextPgpObject();
}
if (message is PgpLiteralData literalData)
{
// Get the literal data stream and copy it to the output
var unc = literalData.GetInputStream();
Streams.PipeAll(unc, outputStream);
}
else if (message is PgpOnePassSignatureList)
{
throw new PgpException("Encrypted message contains a signed message - not literal data.");
}
else
{
throw new PgpException("Message is not a simple encrypted file - type unknown.");
}
// Check for integrity
if (encryptedData.IsIntegrityProtected() && !encryptedData.Verify())
{
throw new PgpException("Message failed integrity check");
}
}
else
{
throw new ArgumentException("No encrypted data found");
}
}
// Reset position for reading
outputStream.Position = 0;
return outputStream;
}
catch (Exception ex)
{
throw new Exception($"PGP decryption failed: {ex.Message}", ex);
}
}
/// <summary>
/// Reads a PGP private key from the provided stream
/// </summary>
private static PgpPrivateKey ReadPrivateKey(Stream inputStream, char[] passPhrase)
{
var pgpSec = new PgpSecretKeyRingBundle(PgpUtilities.GetDecoderStream(inputStream));
// Find a key suitable for decryption
foreach (PgpSecretKeyRing keyRing in pgpSec.GetKeyRings())
{
foreach (PgpSecretKey key in keyRing.GetSecretKeys())
{
if (key.IsSigningKey)
continue; // Skip signing keys, we want encryption keys
// Extract the private key
return key.ExtractPrivateKey(passPhrase);
}
}
throw new ArgumentException("Secret key for decryption not found.");
}