import crypto from 'crypto';
/**
* Converts a hex string to a byte array
* @param {string} hex - Hex string
* @returns {Buffer} - Byte array
*/
function hexStringToByteArray(hex) {
hex = hex.replace(/-/g, '').replace(/\s/g, '');
const bytes = Buffer.alloc(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
/**
* Rounds up number of bits to number of bytes
* @param {number} numBits - Number of bits
* @returns {number} - Number of bytes
*/
function roundupNumBitsToNumBytes(numBits) {
if (numBits < 0) return 0;
return Math.floor(numBits / 8) + ((numBits & 7) !== 0 ? 1 : 0);
}
/**
* Checks and removes HMAC-SHA256 hash from the end of the buffer
* @param {Buffer} bufHashed - Buffer with hash appended
* @param {Buffer} validationKey - Validation key for HMAC
* @param {number} hashSize - Size of the hash (32 for SHA256)
* @returns {Buffer|null} - Buffer without hash, or null if verification fails
*/
function checkHashAndRemove(bufHashed, validationKey, hashSize) {
if (!bufHashed || bufHashed.length < hashSize) {
return null;
}
const dataLength = bufHashed.length - hashSize;
const data = bufHashed.slice(0, dataLength);
const hash = bufHashed.slice(dataLength);
// Compute HMAC-SHA256
const hmac = crypto.createHmac('sha256', validationKey);
hmac.update(data);
const computedHash = hmac.digest();
// Constant-time comparison to prevent timing attacks
let hashCheckFailed = false;
for (let i = 0; i < hashSize; i++) {
if (computedHash[i] !== hash[i]) {
hashCheckFailed = true;
}
}
if (hashCheckFailed) {
return null;
}
return data;
}
/**
* Checks hash at a specific position in the buffer
* @param {Buffer} decryptedCookie - Decrypted cookie buffer
* @param {number} hashIndex - Index where hash starts
* @param {Buffer} validationKey - Validation key for HMAC
* @param {number} hashSize - Size of the hash
* @returns {boolean} - True if hash is valid
*/
function checkHash(decryptedCookie, hashIndex, validationKey, hashSize) {
const data = decryptedCookie.slice(0, hashIndex);
const hash = decryptedCookie.slice(hashIndex, hashIndex + hashSize);
const hmac = crypto.createHmac('sha256', validationKey);
hmac.update(data);
const computedHash = hmac.digest();
if (computedHash.length !== hashSize) {
throw new Error(`Invalid hash length: ${computedHash.length}, expected ${hashSize}`);
}
// Constant-time comparison
let hashCheckFailed = false;
for (let i = 0; i < hashSize; i++) {
if (computedHash[i] !== hash[i]) {
hashCheckFailed = true;
}
}
return !hashCheckFailed;
}
/**
* Decrypts the cookie blob using AES-CBC
* @param {Buffer} cookieBlob - Encrypted cookie blob
* @param {Buffer} decryptionKey - Decryption key
* @param {Buffer} validationKey - Validation key
* @param {number} hashSize - Hash size (32 for SHA256)
* @returns {Buffer} - Decrypted data
*/
function decrypt(cookieBlob, decryptionKey, validationKey, hashSize) {
// Check and remove hash signature
let data = checkHashAndRemove(cookieBlob, validationKey, hashSize);
if (!data || data.length === 0) {
throw new Error('Signature verification failed');
}
// Decrypt using AES-CBC with zero IV
// Determine AES algorithm based on key size
let algorithm;
if (decryptionKey.length === 16) {
algorithm = 'aes-128-cbc';
} else if (decryptionKey.length === 24) {
algorithm = 'aes-192-cbc';
} else if (decryptionKey.length === 32) {
algorithm = 'aes-256-cbc';
} else {
// Default to AES-128, use first 16 bytes
algorithm = 'aes-128-cbc';
}
const iv = Buffer.alloc(16, 0); // Zero IV
const key = decryptionKey.length <= 32 ? decryptionKey : decryptionKey.slice(0, 32);
const decipher = crypto.createDecipheriv(algorithm, key, iv);
let decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
// Remove the IV/salt from the beginning
const keySize = decryptionKey.length * 8;
const ivLength = roundupNumBitsToNumBytes(keySize);
const dataLength = decrypted.length - ivLength;
if (dataLength < 0) {
throw new Error(`Unexpected salt length: ${ivLength}. Total: ${decrypted.length}`);
}
return decrypted.slice(ivLength);
}
/**
* Reads a 7-bit encoded integer from the buffer
* @param {Buffer} buffer - Buffer to read from
* @param {number} offset - Starting offset
* @returns {{value: number, bytesRead: number}} - The integer value and number of bytes read
*/
function read7BitEncodedInt(buffer, offset) {
let value = 0;
let shift = 0;
let bytesRead = 0;
let byte;
do {
if (offset + bytesRead >= buffer.length) {
throw new Error('Invalid 7-bit encoded integer');
}
byte = buffer[offset + bytesRead];
bytesRead++;
value |= (byte & 0x7F) << shift;
shift += 7;
} while ((byte & 0x80) !== 0);
return { value, bytesRead };
}
/**
* Reads a binary string (UTF-16LE) from the buffer
* @param {Buffer} buffer - Buffer to read from
* @param {number} offset - Starting offset
* @returns {{value: string, bytesRead: number}} - The string value and number of bytes read
*/
function readBinaryString(buffer, offset) {
const { value: charCount, bytesRead: lengthBytes } = read7BitEncodedInt(buffer, offset);
const stringBytes = charCount * 2;
if (offset + lengthBytes + stringBytes > buffer.length) {
throw new Error('Invalid binary string length');
}
const stringBuffer = buffer.slice(offset + lengthBytes, offset + lengthBytes + stringBytes);
let result = '';
for (let i = 0; i < charCount; i++) {
const charCode = stringBuffer[i * 2] | (stringBuffer[i * 2 + 1] << 8);
result += String.fromCharCode(charCode);
}
return { value: result, bytesRead: lengthBytes + stringBytes };
}
/**
* Deserializes a FormsAuthenticationTicket from the buffer
* @param {Buffer} serializedTicket - Serialized ticket buffer
* @param {number} serializedTicketLength - Length of the ticket (excluding hash)
* @returns {Object|null} - Deserialized ticket object or null if invalid
*/
function deserializeFormsAuthenticationTicket(serializedTicket, serializedTicketLength) {
const CURRENT_TICKET_SERIALIZED_VERSION = 0x01;
// .NET DateTime ticks epoch: 621355968000000000 ticks = 1970-01-01 00:00:00.0000000 UTC
const EPOCH_TICKS = BigInt('621355968000000000');
let offset = 0;
try {
// Step 1: Read serialized format version (1 byte)
if (offset >= serializedTicket.length) return null;
const serializedFormatVersion = serializedTicket[offset++];
if (serializedFormatVersion !== CURRENT_TICKET_SERIALIZED_VERSION) {
return null;
}
// Step 2: Read ticket version (1 byte)
if (offset >= serializedTicket.length) return null;
const ticketVersion = serializedTicket[offset++];
// Step 3: Read ticket issue date (8 bytes - Int64)
// .NET DateTime ticks are 100-nanosecond intervals since 0001-01-01 00:00:00.0000000 UTC
if (offset + 8 > serializedTicket.length) return null;
const ticketIssueDateUtcTicks = serializedTicket.readBigInt64LE(offset);
offset += 8;
const ticksSinceEpoch = ticketIssueDateUtcTicks - EPOCH_TICKS;
const milliseconds = Number(ticksSinceEpoch) / 10000;
const ticketIssueDateUtc = new Date(milliseconds);
// Step 4: Read spacer (1 byte)
if (offset >= serializedTicket.length) return null;
const spacer = serializedTicket[offset++];
if (spacer !== 0xfe) {
return null;
}
// Step 5: Read ticket expiration date (8 bytes - Int64)
if (offset + 8 > serializedTicket.length) return null;
const ticketExpirationDateUtcTicks = serializedTicket.readBigInt64LE(offset);
offset += 8;
const expirationTicksSinceEpoch = ticketExpirationDateUtcTicks - EPOCH_TICKS;
const expirationMilliseconds = Number(expirationTicksSinceEpoch) / 10000;
const ticketExpirationDateUtc = new Date(expirationMilliseconds);
// Step 6: Read ticket persistence (1 byte)
if (offset >= serializedTicket.length) return null;
const ticketPersistenceFieldValue = serializedTicket[offset++];
let ticketIsPersistent;
if (ticketPersistenceFieldValue === 0) {
ticketIsPersistent = false;
} else if (ticketPersistenceFieldValue === 1) {
ticketIsPersistent = true;
} else {
return null;
}
// Step 7: Read ticket username
const usernameResult = readBinaryString(serializedTicket, offset);
offset += usernameResult.bytesRead;
const ticketName = usernameResult.value;
// Step 8: Read ticket custom data
const userDataResult = readBinaryString(serializedTicket, offset);
offset += userDataResult.bytesRead;
const ticketUserData = userDataResult.value;
// Step 9: Read ticket cookie path
const cookiePathResult = readBinaryString(serializedTicket, offset);
offset += cookiePathResult.bytesRead;
const ticketCookiePath = cookiePathResult.value;
// Step 10: Read footer (1 byte)
if (offset >= serializedTicket.length) return null;
const footer = serializedTicket[offset++];
if (footer !== 0xff) {
return null;
}
// Step 11: Verify we consumed the entire payload
if (offset !== serializedTicketLength) {
return null;
}
// Convert UTC dates to local time
const ticketIssueDate = new Date(ticketIssueDateUtc);
const ticketExpiration = new Date(ticketExpirationDateUtc);
return {
version: ticketVersion,
name: ticketName,
issueDate: ticketIssueDate,
expiration: ticketExpiration,
isPersistent: ticketIsPersistent,
userData: ticketUserData,
cookiePath: ticketCookiePath,
expired: new Date() > ticketExpiration
};
} catch (error) {
// Invalid ticket format - return null to indicate failure
// This matches the C# implementation behavior
return null;
}
}
/**
* Decrypts an authentication cookie
* @param {string} cookieString - Hex-encoded encrypted cookie string
* @param {string} decryptionKeyHex - Hex-encoded decryption key
* @param {string} validationKeyHex - Hex-encoded validation key
* @returns {Object|null} - Decrypted ticket object or null if decryption fails
*/
function decryptCookie(cookieString) {
const validationKeyHex = 'EDAD268C6CA3E5E0160B8D5D4DABFDBF33A5E132D33C943E0AFDB64920D804309074DEFA97962FF473D2C60E04CC3E025F11FEABF053BD1D0F21B6815B225371';
const decryptionKeyHex = '278B0D5F1CF1397E2370BE2C5B62F8724D4879491917DAEA';
const HASH_SIZE = 32; // SHA256 hash size
// Convert hex strings to byte arrays
let cookieBlob;
try {
if (cookieString.length % 2 !== 0) {
return null;
}
cookieBlob = hexStringToByteArray(cookieString);
} catch (error) {
// Invalid hex string format - return null to indicate failure
return null;
}
if (!cookieBlob || cookieBlob.length === 0) {
return null;
}
const decryptionKey = hexStringToByteArray(decryptionKeyHex);
const validationKey = hexStringToByteArray(validationKeyHex);
// Decrypt the cookie
let decryptedBlob;
try {
decryptedBlob = decrypt(cookieBlob, decryptionKey, validationKey, HASH_SIZE);
} catch (error) {
throw new Error(`Decryption failed: ${error.message}`);
}
// Verify hash after decryption
const ticketLength = decryptedBlob.length - HASH_SIZE;
if (ticketLength < 0) {
throw new Error('Invalid decrypted blob length');
}
const isValidHash = checkHash(decryptedBlob, ticketLength, validationKey, HASH_SIZE);
if (!isValidHash) {
throw new Error('Invalid hash generated after decryption');
}
// Deserialize the ticket
const ticket = deserializeFormsAuthenticationTicket(decryptedBlob, ticketLength);
const profileId = ticket.userData.split('|')[0];
const globalCustomerId = ticket.userData.split('|')[1];
return {
profileId,
globalCustomerId
};
}
// Export functions for use as a module
export {
decryptCookie,
hexStringToByteArray,
deserializeFormsAuthenticationTicket
};