diablo2-web/src/mpq/crypt.ts

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, '\\')
}