UNPKG

@abextm/cache2

Version:

Utilities for reading OSRS "caches"

286 lines (251 loc) 7.13 kB
import { ArchiveData } from "./Cache.js"; import { Reader } from "./Reader.js"; import { CompressionType, XTEAKey } from "./types.js"; const ROUNDS = 32; const GOLDEN = 0x9E3779B9; const IV = ~~(ROUNDS * GOLDEN); export function decryptXTEA(data: Uint8Array, key: XTEAKey): Uint8Array { if (key.length != 4) { throw new Error("invalid key " + key); } const k = new Uint32Array(key); const d = new DataView(data.buffer, data.byteOffset, (~~(data.byteLength / 8)) * 8); let out = new Uint8Array(data.byteLength); let outdv = Reader.makeViewOf(DataView, out); for (let o = 0; o < d.byteLength; o += 8) { let v0 = d.getInt32(o); let v1 = d.getInt32(o + 4); let sum = IV; for (let r = 0; r < ROUNDS; r++) { v1 = ~~(v1 - ((((v0 << 4) ^ (v0 >>> 5)) + v0) ^ (sum + k[(sum >>> 11) & 3]))); sum -= GOLDEN; v0 = ~~(v0 - ((((v1 << 4) ^ (v1 >>> 5)) + v1) ^ (sum + k[sum & 3]))); } outdv.setInt32(o, v0); outdv.setInt32(o + 4, v1); } for (let o = d.byteLength; o < data.byteLength; o++) { out[o] = data[o]; } return out; } function decryptXTEABlock(input: Int32Array, out: Int32Array, k: Int32Array, kIndex: number) { let v0 = input[0]; let v1 = input[1]; let sum = IV; for (let r = 0; r < ROUNDS; r++) { v1 = ~~(v1 - ((((v0 << 4) ^ (v0 >>> 5)) + v0) ^ (sum + k[kIndex + ((sum >>> 11) & 3)]))); sum -= GOLDEN; v0 = ~~(v0 - ((((v1 << 4) ^ (v1 >>> 5)) + v1) ^ (sum + k[kIndex + (sum & 3)]))); } out[0] = v0; out[1] = v1; } function compareKey(a: Int32Array, ai: number, b: XTEAKey): number { for (let i = 0; i < 4; i++) { let d = (b[i] >>> 0) - (a[ai + i] >>> 0); if (d != 0) { return d; } } return 0; } // This is so stupid. JS Set cannot hold non primitives, and packing the keys // into a bigint means we spend ~50% of tryDecrypt unpacking it, and another 20% // in Set's next class KeySet { length = 0; bits: number; data: Int32Array; constructor(expectedSize = 8) { this.bits = Math.max(3, ~~Math.log2(expectedSize)); this.data = new Int32Array(4 << this.bits); } add(key: XTEAKey): boolean { if (this.length >= this.data.length * .75) { this.grow(); } if (key[0] === 0 && key[1] === 0 && key[2] === 0 && key[3] === 0) { return false; } let data = this.data; let index = (key[0] >>> (32 - this.bits)) * 4; for (;;) { if (data[index] === 0 && data[index + 1] === 0 && data[index + 2] === 0 && data[index + 3] === 0) { this.data.set(key, index); this.length += 4; return true; } let d = compareKey(data, index, key); if (d == 0) { return false; } if (d < 0) { let next = [ this.data[index], this.data[index + 1], this.data[index + 2], this.data[index + 3], ] satisfies XTEAKey; this.data.set(key, index); key = next; } index = (index + 4) & (this.data.length - 1); } } grow(): void { let old = this.data; let it = this.iterator(); this.bits++; this.data = new Int32Array(4 << this.bits); this.length = 0; for (let index: number; (index = it()) != -1;) { this.add([old[index], old[index + 1], old[index + 2], old[index + 3]]); } } iterator(): () => number { let checkAll = true; let index = -4; let data = this.data; return () => { for (;;) { index += 4; if (index >= data.length) { return -1; } if (data[index] === 0) { if (!checkAll || (data[index + 1] === 0 && data[index + 2] === 0 && data[index + 3] === 0)) { continue; } } else { checkAll = false; } return index; } }; } } export class XTEAKeyManager { public unknownKeys = new KeySet(); public keysByMapSquare = new Map<number, KeySet>(); public allKeys = new KeySet(); constructor() { } public loadKeys(document: any): number { if (typeof document !== "object") { throw new Error(`document must be an object or array, not ${typeof document}`); } let count = 0; if (Array.isArray(document)) { for (let item of document) { if (typeof item !== "object") { throw new Error(`document must contain objects or keys, not ${typeof item}`); } if (Array.isArray(item)) { // OpenRS2 all key list // XTEAKey[] this.unknownKeys.add(item as XTEAKey); if (this.allKeys.add(item as XTEAKey)) { count++; } } else { // OpenRS2 per cache key list // also matches polar/runestats // {mapsquare: packed region id, key: XTEAKey}[] // RuneLite xtea service // {region: packed region id, keys: XTEAKey}[] let key = item.key ?? item.keys; let mapsquare = item.mapsquare ?? item.region; if (key === undefined || mapsquare === undefined) { throw new Error(`document must contain key & mapsquare/region, not ${JSON.stringify(item)}`); } if (this.putKeyForMapsquare(mapsquare, key)) { count++; } } } } else { for (let [mapsq, item] of Object.entries(document)) { if (Array.isArray(item)) { // RuneLite xtea cache // {packed region id: XTEAKey} if (this.putKeyForMapsquare(~~mapsq, item as XTEAKey)) { count++; } } else { throw new Error(`document must contain keys, not ${JSON.stringify(item)}`); } } } return count; } private putKeyForMapsquare(mapsquare: number, key: XTEAKey): boolean { let set = this.keysByMapSquare.get(mapsquare); if (!set) { set = new KeySet(); this.keysByMapSquare.set(mapsquare, set); } set.add(key); return this.allKeys.add(key); } public tryDecrypt(ad: ArchiveData, region?: number): Error | undefined { if (this.allKeys.length <= 0) { return new Error(`No keys added`); } if (ad.decryptedData) { return; } let crypted = ad.getCryptedBlob(); if (crypted.length < 8) { // last block is not encrypted return; } let keys = [undefined, this.allKeys]; if (region) { let ikeys = this.keysByMapSquare.get(region); if (ikeys) { keys = [undefined, ikeys, this.unknownKeys]; } else { keys = [undefined, this.unknownKeys]; } } let firstBlock = new Int32Array(2); { let dv = Reader.makeViewOf(DataView, crypted); firstBlock[0] = dv.getInt32(0); firstBlock[1] = dv.getInt32(4); } let decrypted = Int32Array.from(firstBlock); let compression = ad.compression; let error: any; let hitUndefined = 0; for (let keyset of keys) { let data = keyset?.data; let iterator = keyset?.iterator() ?? (() => hitUndefined++ === 0 ? 0 : -1); for (let index: number; (index = iterator()) != -1;) { if (data) { decryptXTEABlock(firstBlock, decrypted, data, index); } if (compression === CompressionType.GZIP) { if (decrypted[1] != 0x1f8b0800) { // not the right header, can discard now continue; } } try { ad.key = data ? Array.from(data.subarray(index, index + 4)) as XTEAKey : undefined; ad.getDecryptedData(); return undefined; } catch (e) { ad.key = undefined; if (!error) { error = e; } } } } return error ? new Error(`no matching keys (${error})`, { cause: error }) : new Error(`no matching keys`); } }