Instantly share code, notes, and snippets.
Last active
April 17, 2024 11:47
-
Star
7
(7)
You must be signed in to star a gist -
Fork
1
(1)
You must be signed in to fork a gist
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Super!!! By any chance have you done a protect function as well?