Source: server/util/unprotectAspNetData.js

import padStart from 'lodash/padStart';
import leb128 from 'leb128';
import crypto from 'crypto';
import debugLib from 'debug';

const debug = debugLib('SlimmingWorld:unprotectAspNetData');

// magic header used to identify an identity cookie
const MAGIC_HEADER = 0x09f0c9f0;
// key id size in bytes
const SIZE_KEY_ID = 16;
// size of key modifier according to the CbcAuthenticatedEncryptor:
// https://github.com/aspnet/DataProtection/blob/dev/src/Microsoft.AspNetCore.DataProtection/Cng/CbcAuthenticatedEncryptor.cs
const SIZE_KEY_MODIFIER = 16;
// properties of the symmetric encryption algorithm (AES_256-CBC):
const SIZE_SYMMETRIC_ALGORITHM_KEY = 32;
const SIZE_SYMMETRIC_ALGORITHM_BLOCK = 16;
// properties of the validation hashing algorithm (HMAC-SHA256):
const SIZE_VALIDATION_HMAC_DIGEST = 32;
// variables for the SP800-108 algorithm. See:
// https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-108.pdf
const SIZE_SP800_108_L = SIZE_SYMMETRIC_ALGORITHM_KEY + SIZE_VALIDATION_HMAC_DIGEST;
const SIZE_SP800_108_PRF_DIGEST = 64; /* digest of HMAC-SHA512) */

/**
 * Unprotects a cookie encrypted using ASP.NET Core Identity with the default settings.
 *
 * More specifically, the default settings entail the following:
 * - The protected payload is base64 url encoded:
 *   https://tools.ietf.org/html/rfc4648#section-5
 * - The protected payload is formatted according to:
 *   https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/implementation/authenticated-encryption-details?view=aspnetcore-2.1
 * - AES-256-CBC is used for encryption, HMAC-SHA5256 is used for validation. The encryptor used is
 * an instance of CbcAuthenticatedEncryptor. This cipher text format can be found in the source
 * code:
 *   https://github.com/aspnet/DataProtection/blob/dev/src/Microsoft.AspNetCore.DataProtection/Cng/CbcAuthenticatedEncryptor.cs#L152
 * - The AES-256-CBC and HMAC-SHA5256 keys are derived from a master key using SP800-108:
 *   https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-108.pdf
 * - SP800-108 key derivation runs in counter mode with HMAC-SHA512 as PRF
 * - The label input to SP800-108 derivation is an additionalAuthenticatedData (AAD) buffer. The
 *   format is undocumented but derived from the ASP.NET source code:
 *   https://github.com/aspnet/DataProtection/blob/dev/src/Microsoft.AspNetCore.DataProtection/KeyManagement/KeyRingBasedDataProtector.cs#L314
 * - Context headers are generated according to the following:
 *   https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/implementation/context-headers?view=aspnetcore-2.1
 *
 * @param protectedPayload {Buffer} The payload from the identity cookie
 * @param purposeStrings {Array<string>} An array of purpose strings passed to the IDataProtector
 * instance that performed the cookie encryption. These purposes are used to create the AAD which
 * is used as input to the key derivation function. See:
 * https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/consumer-apis/purpose-strings?view=aspnetcore-2.1
 * @param getMasterKeyById {keyId => Promise} A function should be provided to this util that
 * retrieves a master key given a key id. This function receives a string keyId, and should return
 * a Promise that resolves with the corresponding base64-encoded master key. The promise should
 * reject if the key was not found.
 * Please note: the master key should be encoded as plain base64, unlike the cookie itself which
 * is encoded as URL-safe base64
 * For information on how to implement this function, see "Key management in ASP.NET Core":
 * https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/implementation/key-management?view=aspnetcore-2.1
 * @param azureBlobConnectionString A configuration string from the environmentConfig
 * @param containerName The name of the container to get the id from keys.xml
 * @return {Promise<string|null>} A Promise that resolves with the unencrypted cookie
 */
function unprotectAspNetData(
  protectedPayload,
  purposeStrings,
  getMasterKeyById,
  azureBlobConnectionString,
  containerName,
) {
  let cursor = 0;

  if (!verifyMagicHeader(protectedPayload)) {
    debug('Protected payload did not start with expected magic header');
    return null;
  }
  cursor += 4;

  const keyIdBuffer = protectedPayload.slice(cursor, cursor + SIZE_KEY_ID);
  cursor += SIZE_KEY_ID;
  let formatedKeyId;
  try {
    formatedKeyId = formatKeyId(keyIdBuffer);
  } catch (e) {
    debug('Could not read key id from identity cookie');
    return null;
  }

  return getMasterKeyById(formatedKeyId, containerName, azureBlobConnectionString)
    .catch(err => {
      debug(err);
      debug(`Could not find master key for key id "${formatedKeyId}".`);
      return null;
    })
    .then(masterKeyBase64 => {
      const masterKeyBuffer = Buffer.from(masterKeyBase64, 'base64');
      const aadBuffer = generateAad(keyIdBuffer, purposeStrings);

      const contextHeaderBuffer = getContextHeader();

      // below we will read the different sections of the cipher text. This is assumed to
      // have the following format:
      // { keyModifier | IV | encryptedData | MAC(IV | encryptedPayload) }
      const modifierBuffer = protectedPayload.slice(cursor, cursor + SIZE_KEY_MODIFIER);
      cursor += SIZE_KEY_MODIFIER;
      // the initialization vector is used to initialize the symmetric encryption algorithm, so
      // the size of iv will be equal to the block size of the algorithm
      const ivBuffer = protectedPayload.slice(cursor, cursor + SIZE_SYMMETRIC_ALGORITHM_BLOCK);
      cursor += SIZE_KEY_MODIFIER;

      // the remainder of the cipher text is encrypted data + MAC tag.
      // we are only interested in the encrypted data, so we strip the HMAC digest.
      const encryptedDataBuffer = protectedPayload.slice(
        cursor,
        protectedPayload.length - SIZE_VALIDATION_HMAC_DIGEST,
      );

      const contextBuffer = Buffer.concat([contextHeaderBuffer, modifierBuffer]);
      const derivedKeyBuffer = deriveKeysSP800_108_CTR_HMAC512(
        masterKeyBuffer,
        aadBuffer,
        contextBuffer,
      );
      const encryptionKeyBuffer = derivedKeyBuffer.slice(0, SIZE_SYMMETRIC_ALGORITHM_KEY);
      const decipher = crypto.createDecipheriv('aes-256-cbc', encryptionKeyBuffer, ivBuffer);
      const outputStart = decipher.update(encryptedDataBuffer);
      const outputEnd = decipher.final();

      return Buffer.concat([outputStart, outputEnd]);
    });
}

/**
 * Verifies that the protected payload starts with the magic header as specified in
 * https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/implementation/authenticated-encryption-details?view=aspnetcore-2.1
 * @param protectedPayload {Buffer} The decoded protected payload
 */
function verifyMagicHeader(protectedPayload) {
  return protectedPayload.readInt32BE(0) === MAGIC_HEADER;
}

/**
 * Formats a key id from the given Buffer.
 * @param keyIdBuffer {Buffer} The buffer to read the key from
 * @returns {string} A key id formatted as the output of the Guid.toString() method:
 * https://msdn.microsoft.com/en-us/library/560tzess(v=vs.110).aspx
 * The format looks like so: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
 */
function formatKeyId(keyIdBuffer) {
  const padding = [8, 4, 4, 4, 12];

  return [
    keyIdBuffer.readUInt32LE(0),
    keyIdBuffer.readUInt16LE(4),
    keyIdBuffer.readUInt16LE(6),
    keyIdBuffer.readUInt16BE(8),
    keyIdBuffer.readUIntBE(10, 6),
  ]
    .map((int, index) => padStart(int.toString(16), padding[index], '0'))
    .join('-');
}

/**
 * Generates the additionalAuthenticatedData (AAD) that is used as label in the key derivation
 * algorithm. The format is undocumented but derived from the ASP.NET source code:
 * https://github.com/aspnet/DataProtection/blob/dev/src/Microsoft.AspNetCore.DataProtection/KeyManagement/KeyRingBasedDataProtector.cs#L314
 * @param keyIdBuffer {Buffer} A buffer containing the 128bit key id.
 * @param purposeStrings {Array<string>} An array of purpose strings passed to the IDataProtector
 * instance that performed the cookie encryption. These purposes are used to create the AAD which
 * is used as input to the key derivation function. See:
 * https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/consumer-apis/purpose-strings?view=aspnetcore-2.1
 * @returns {Buffer}
 */
function generateAad(keyIdBuffer, purposeStrings) {
  // we'll append the purposes themselves afterwards
  let aadSizeBytes = 4 /* 32-bit magic header */ + SIZE_KEY_ID + 4; /* 32-bit purpose count */

  const purposeBuffers = [];

  purposeStrings.forEach(purposeString => {
    const purposeBuffer = Buffer.from(purposeString, 'utf8');

    // size of purpose in bytes
    const purposeSize = purposeBuffer.length;

    // The purpose is written to the AAD using the BinaryWriter.Write method
    // This method prefixes the string with the string length encoded using the LEB128 format:
    // https://en.wikipedia.org/wiki/LEB128
    const purposeLengthLeb = leb128.unsigned.encode(purposeSize);

    aadSizeBytes += purposeSize;
    aadSizeBytes += purposeLengthLeb.length;
    purposeBuffers.push(purposeLengthLeb);
    purposeBuffers.push(purposeBuffer);
  });

  const aadBuffer = Buffer.alloc(aadSizeBytes);
  let cursor = 0;

  // write magic header
  aadBuffer.writeUInt32BE(MAGIC_HEADER, cursor);
  cursor += 4;

  // write key id
  keyIdBuffer.copy(aadBuffer, cursor);
  cursor += SIZE_KEY_ID;

  // write purpose count
  aadBuffer.writeUInt32BE(purposeStrings.length, cursor);
  cursor += 4;

  purposeBuffers.forEach(buffer => {
    buffer.copy(aadBuffer, cursor);
    cursor += buffer.length;
  });

  if (cursor !== aadSizeBytes) {
    throw new Error(`Unexpected aad size. Expected ${aadSizeBytes}, got ${cursor}`);
  }

  return aadBuffer;
}

/**
 * Generates a context header according to the following specification:
 * https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/implementation/context-headers?view=aspnetcore-2.1
 * @returns {Buffer} the generated context header
 */
function getContextHeader() {
  const emptyBuffer = Buffer.alloc(0);
  // we run key derivation with empty parameters to build keys used in the context header
  const derivedKeyBuffer = deriveKeysSP800_108_CTR_HMAC512(emptyBuffer, emptyBuffer, emptyBuffer);

  // encrypt empty string
  const encryptionKeyBuffer = derivedKeyBuffer.slice(0, SIZE_SYMMETRIC_ALGORITHM_KEY);
  // iv will be filled with 0x00 by default
  const iv = Buffer.alloc(SIZE_SYMMETRIC_ALGORITHM_BLOCK);
  const cipher = crypto.createCipheriv('aes-256-cbc', encryptionKeyBuffer, iv);
  const emptyEncryptionOutputBuffer = cipher.final();

  // hash empty string
  const hmacKeyBuffer = derivedKeyBuffer.slice(SIZE_SYMMETRIC_ALGORITHM_KEY);
  const hmac = crypto.createHmac('sha256', hmacKeyBuffer);
  const emptyHashOutputBuffer = hmac.digest();

  const markerBuffer = Buffer.from([0x00, 0x00]);
  const sizesBuffer = Buffer.alloc(4 * 4);
  sizesBuffer.writeUInt32BE(SIZE_SYMMETRIC_ALGORITHM_KEY, 0);
  sizesBuffer.writeUInt32BE(SIZE_SYMMETRIC_ALGORITHM_BLOCK, 4);
  sizesBuffer.writeUInt32BE(SIZE_VALIDATION_HMAC_DIGEST, 8);
  sizesBuffer.writeUInt32BE(SIZE_VALIDATION_HMAC_DIGEST, 12);

  return Buffer.concat([
    markerBuffer,
    sizesBuffer,
    emptyEncryptionOutputBuffer,
    emptyHashOutputBuffer,
  ]);
}

/**
 * Executes the SP800-108 algorithm in counter mode with HMAC-SHA512 as PRF
 * @param masterKeyBuffer {Buffer}
 * @param labelBuffer {Buffer}
 * @param contextBuffer {Buffer}
 *
 * @returns {Buffer} A Buffer containing the result of the SP800-108 key derivation algorithm
 */
// eslint-disable-next-line camelcase
function deriveKeysSP800_108_CTR_HMAC512(masterKeyBuffer, labelBuffer, contextBuffer) {
  const prfInputSize =
    4 /* counter 32bit int */ +
    labelBuffer.length +
    1 /* 0x00 separator */ +
    contextBuffer.length +
    4; /* L 32bit int */

  const n = Math.ceil(SIZE_SP800_108_L / SIZE_SP800_108_PRF_DIGEST);
  const resultBuffer = Buffer.alloc(n * SIZE_SP800_108_PRF_DIGEST);
  // allocate a buffer for the 32int counter and L variables
  const counterBuffer = Buffer.alloc(4);
  const LBuffer = Buffer.alloc(4);
  // size L must be in bits, not bytes
  LBuffer.writeInt32BE(SIZE_SP800_108_L * 8);
  // buffer will be filled with 0x00 by default
  const separatorBuffer = Buffer.alloc(1);

  for (let i = 1; i <= n; i++) {
    counterBuffer.writeInt32BE(i, 0);
    const prfInput = Buffer.concat(
      [counterBuffer, labelBuffer, separatorBuffer, contextBuffer, LBuffer],
      prfInputSize,
    );
    const hmac = crypto.createHmac('sha512', masterKeyBuffer);
    hmac.update(prfInput);
    hmac.digest().copy(resultBuffer, SIZE_SP800_108_PRF_DIGEST * (i - 1));
  }

  // strip off excess bytes before return
  return resultBuffer.slice(0, SIZE_SP800_108_L);
}

export default unprotectAspNetData;