160 lines
6.0 KiB
TypeScript
160 lines
6.0 KiB
TypeScript
/**
|
|
* MPQ (Storm) cryptography: the shared crypt table, `HashString`, and the
|
|
* block cipher.
|
|
*
|
|
* Semantics follow the public StormLib reference implementation (MIT,
|
|
* Copyright (c) Ladislav Zezula); this file is an independent TypeScript
|
|
* port. All arithmetic is unsigned 32-bit with explicit wrapping, because
|
|
* the format depends on exact overflow behaviour.
|
|
*/
|
|
|
|
/** Hash selector: table index (start of the probe chain). */
|
|
export const HASH_TABLE_OFFSET = 0
|
|
/** Hash selector: name hash A (hash table entry comparison). */
|
|
export const HASH_NAME_A = 1
|
|
/** Hash selector: name hash B (hash table entry comparison). */
|
|
export const HASH_NAME_B = 2
|
|
/** Hash selector: per-file encryption key. */
|
|
export const HASH_FILE_KEY = 3
|
|
|
|
/** Charset size: one hash alphabet per selector occupies 0x100 slots. */
|
|
const CHARSET_SIZE = 0x100
|
|
|
|
/**
|
|
* The 0x500-entry Storm crypt table: 5 alphabets of 0x100 values. The last
|
|
* alphabet (0x400..0x4FF) is the cipher's per-key-byte seed table.
|
|
*/
|
|
const CRYPT_TABLE: Uint32Array = (() => {
|
|
const table = new Uint32Array(5 * CHARSET_SIZE)
|
|
let seed = 0x00100001
|
|
for (let index1 = 0; index1 < CHARSET_SIZE; index1 += 1) {
|
|
let index2 = index1
|
|
for (let i = 0; i < 5; i += 1, index2 += CHARSET_SIZE) {
|
|
seed = ((((Math.imul(seed, 125) + 3) >>> 0) % 0x2aaaab) >>> 0)
|
|
const temp1 = ((seed & 0xffff) << 0x10) >>> 0
|
|
seed = ((((Math.imul(seed, 125) + 3) >>> 0) % 0x2aaaab) >>> 0)
|
|
const temp2 = seed & 0xffff
|
|
table[index2] = (temp1 | temp2) >>> 0
|
|
}
|
|
}
|
|
return table
|
|
})()
|
|
|
|
/**
|
|
* Storm's case-insensitive string hash.
|
|
*
|
|
* Archive names are ASCII; the uppercase fold is applied to ASCII only so a
|
|
* stray non-ASCII byte cannot alias two distinct names.
|
|
*
|
|
* @param name - archive file name (either separator is accepted by callers).
|
|
* @param hashType - one of the `HASH_*` selectors.
|
|
* @returns the 32-bit hash.
|
|
*/
|
|
export function hashString(name: string, hashType: number): number {
|
|
let seed1 = 0x7fed7fed
|
|
let seed2 = 0xeeeeeeee
|
|
const base = hashType * CHARSET_SIZE
|
|
for (let i = 0; i < name.length; i += 1) {
|
|
const code = name.charCodeAt(i)
|
|
const ch = (code >= 0x61 && code <= 0x7a ? code - 0x20 : code) & 0xff
|
|
seed1 = (CRYPT_TABLE[base + ch]! ^ ((seed1 + seed2) >>> 0)) >>> 0
|
|
seed2 = (ch + seed1 + seed2 + (((seed2 << 5) >>> 0)) + 3) >>> 0
|
|
}
|
|
return seed1
|
|
}
|
|
|
|
/**
|
|
* Decrypt one block in place with the MPQ block cipher.
|
|
*
|
|
* @param data - buffer to decrypt; `byteLength` must be a multiple of 4.
|
|
* @param key - the 32-bit file/table key.
|
|
*/
|
|
export function decryptBlock(data: Uint8Array, key: number): void {
|
|
const words = data.byteLength >>> 2
|
|
if (words === 0) return
|
|
const view = new DataView(data.buffer, data.byteOffset, words << 2)
|
|
let seed = 0xeeeeeeee
|
|
let k = key >>> 0
|
|
for (let i = 0; i < words; i += 1) {
|
|
seed = (seed + CRYPT_TABLE[0x400 + (k & 0xff)]!) >>> 0
|
|
const ch = (view.getUint32(i * 4, true) ^ ((k + seed) >>> 0)) >>> 0
|
|
k = ((((~k << 0x15) >>> 0) + 0x11111111 | (k >>> 0x0b)) >>> 0)
|
|
seed = (ch + seed + (((seed << 5) >>> 0)) + 3) >>> 0
|
|
view.setUint32(i * 4, ch, true)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encrypt one block in place with the MPQ block cipher.
|
|
*
|
|
* MPQ's cipher is its own inverse in the sense that both directions run the same
|
|
* key schedule: the schedule is advanced with the *plaintext* word, so encrypting
|
|
* reads the plaintext, writes the ciphertext, and feeds the plaintext into the
|
|
* schedule — while `decryptBlock` reads the ciphertext, recovers the plaintext,
|
|
* and feeds that same plaintext in. Keeping the two explicit matters for the
|
|
* writer, where using the decrypt path would corrupt the schedule.
|
|
*
|
|
* @param data - buffer to encrypt; `byteLength` must be a multiple of 4.
|
|
* @param key - the 32-bit file/table key.
|
|
*/
|
|
export function encryptBlock(data: Uint8Array, key: number): void {
|
|
const words = data.byteLength >>> 2
|
|
if (words === 0) return
|
|
const view = new DataView(data.buffer, data.byteOffset, words << 2)
|
|
let seed = 0xeeeeeeee
|
|
let k = key >>> 0
|
|
for (let i = 0; i < words; i += 1) {
|
|
seed = (seed + CRYPT_TABLE[0x400 + (k & 0xff)]!) >>> 0
|
|
const plain = view.getUint32(i * 4, true)
|
|
const cipher = (plain ^ ((k + seed) >>> 0)) >>> 0
|
|
k = ((((~k << 0x15) >>> 0) + 0x11111111 | (k >>> 0x0b)) >>> 0)
|
|
seed = (plain + seed + (((seed << 5) >>> 0)) + 3) >>> 0
|
|
view.setUint32(i * 4, cipher, true)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The encryption key of one archive member.
|
|
*
|
|
* The key is derived from the *plain* file name — the segment after the last
|
|
* separator — while the hash-table lookup uses the full path. The two are the
|
|
* same only for root-level names, so a path-qualified lookup that hashes the
|
|
* whole name silently produces a key that decrypts to noise.
|
|
*
|
|
* @param name - archive file name, either separator.
|
|
* @param blockOffset - stored offset of the file's first byte.
|
|
* @param fileSize - uncompressed size of the file.
|
|
* @param fixKey - the entry carries `MPQ_FILE_FIX_KEY`.
|
|
* @returns the file key.
|
|
*/
|
|
export function fileKey(name: string, blockOffset: number, fileSize: number, fixKey: boolean): number {
|
|
const base = hashString(plainName(normalizeName(name)), HASH_FILE_KEY)
|
|
return fixKey ? (((base + blockOffset) >>> 0) ^ fileSize) >>> 0 : base
|
|
}
|
|
|
|
/**
|
|
* The plain (directory-less) form of an archive name.
|
|
*
|
|
* @param name - archive name, either separator.
|
|
* @returns the trailing path segment.
|
|
*/
|
|
export function plainName(name: string): string {
|
|
const cut = Math.max(name.lastIndexOf('\\'), name.lastIndexOf('/'))
|
|
return cut === -1 ? name : name.slice(cut + 1)
|
|
}
|
|
|
|
/** The fixed key of the hash table. */
|
|
export const HASH_TABLE_KEY = hashString('(hash table)', HASH_FILE_KEY)
|
|
/** The fixed key of the block table. */
|
|
export const BLOCK_TABLE_KEY = hashString('(block table)', HASH_FILE_KEY)
|
|
|
|
/**
|
|
* Archive names use backslash separators internally; callers may pass either.
|
|
*
|
|
* @param name - caller-supplied name.
|
|
* @returns the normalized name.
|
|
*/
|
|
export function normalizeName(name: string): string {
|
|
return name.replace(/\//g, '\\')
|
|
}
|