Skip to content

Instantly share code, notes, and snippets.

@flut1
Last active April 17, 2024 11:47
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save flut1/110d31ea24cc352838681cca324544e2 to your computer and use it in GitHub Desktop.
Save flut1/110d31ea24cc352838681cca324544e2 to your computer and use it in GitHub Desktop.
Unprotects a cookie in Node.JS that was encrypted using ASP.NET Core Identity with the default settings.
import { padStart } from 'lodash';
import leb128 from 'leb128';
import crypto from 'crypto';
// 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 identityCookie {string} A base64 url encoded 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
* @return {Promise<string|null>} A Promise that resolves with the unencrypted cookie
*/
function unprotectAspNetIdentityCookie(identityCookie, purposeStrings, getMasterKeyById) {
let protectedPayload;
let cursor = 0;
try {
protectedPayload = dotNetBase64UrlDecode(identityCookie);
} catch (e) {
console.log(`Could not decode identity cookie:${e}`);
return null;
}
if (!verifyMagicHeader(protectedPayload)) {
console.log('Identity cookie 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) {
console.log('Could not read key id from identity cookie');
return null;
}
return getMasterKeyById(formatedKeyId)
.catch(() => {
console.log(`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]);
});
}
/**
* Decodes a base64 encoded string tha has been encoded using the
* HttpServerUtility.UrlTokenEncode method. See:
* {@link https://msdn.microsoft.com/en-us/library/system.web.httpserverutility.urltokenencode(v=vs.110).aspx}
*
* @param data {string} The data to decode
* @returns {Buffer} A NodeJS buffer with the decoded data
*/
function dotNetBase64UrlDecode(data) {
const processed = data
// replace - with +
.replace(/-/g, '+')
// replace _ with /
.replace(/_/g, '/')
// replace the last digit with that number of '=' characters
.replace(/\d$/, (match) => {
switch (match) {
case '1':
return '=';
case '2':
return '==';
case '3':
return '===';
default:
return '';
}
});
return Buffer.from(processed, 'base64');
}
/**
* 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 unprotectAspNetIdentityCookie;
@maganuk
Copy link

maganuk commented Mar 12, 2021

Super!!! By any chance have you done a protect function as well?

@flut1
Copy link
Author

flut1 commented Mar 12, 2021

@maganuk unfortunately I have not. The server I wrote it for only needed read access

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment