diff --git a/README.md b/README.md index 88b4e51..c1ff954 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ # @runejs/core -Core logging, networking, and buffer functionality for RuneJS applications. +Core logging, networking, compression, encryption, and additional buffer functionality for RuneJS applications. -### Logger -* `RuneLogger` singleton Pino `logger` wrapper: +### @runejs/core +* `logger` is exported as a singleton Pino logging wrapper, offering the following functions: * `logger.info(...messages)` * `logger.debug(...messages)` * `logger.warn(...messages)` @@ -19,20 +19,26 @@ Core logging, networking, and buffer functionality for RuneJS applications. * Ability to set the Pino logging pretty print config value via `setLoggerPrettyPrint(boolean)` * Setting of _all_ Pino logging options via `setLoggerOptions(Pino.LoggerOptions)` -### Byte Buffer -* Node `Uint8Array` wrapper with additional utility functions. -* Unified configurable `get` and `put` methods to easily move bytes within the buffer. -* Int24, Smart, Long and String type support. -* Big endian, little endian, and mixed endian support. -* Bit access through `openBitBuffer()`, `putBits()`, and `closeBitBuffer()` - -### Networking Components - -#### openServer(name, host, port, connectionHandlerFactory) -Spins up a new Node Socket server with the specified host and port. - -#### SocketConnectionHandler -Handles connections made to a Socket server opened via `openServer()` - -#### ServerConfigOptions -Options for a configured Socket server, imported using the `parseServerConfig()` function. +### @runejs/core/buffer +* `ByteBuffer` is the main export. + * Node `Uint8Array` wrapper with additional utility functions. + * Unified configurable `get` and `put` methods to easily move bytes within the buffer. + * Int24, Smart, Long and String type support. + * Big endian, little endian, and mixed endian support. + * Bit access through `openBitBuffer()`, `putBits()`, and `closeBitBuffer()` + +### @runejs/core/net +* `SocketServer` + * Handles connections made to a RuneJS socket server. +* `SocketServer.launch(serverName, hostName, port, connectionHandlerFactory)` + * Spins up a new Node Socket server with the specified host and port. +* `ServerConfigOptions` + * Options for a configured Socket server, imported using the `parseServerConfig()` function. + +### @runejs/core/compression +* Exported class `Gzip` handles Gzip compression and decompression. +* Exported class `Bzip2` handles Bzip2 compression and decompression. + +### @runejs/core/encryption +Provides XTEA encryption and decryption functionality, as well as a key file loader. +* Exported as class `Xtea` diff --git a/package-lock.json b/package-lock.json index 9aaa8a3..e0b98e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@runejs/core", - "version": "1.5.4", + "version": "1.6.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -312,6 +312,11 @@ "uri-js": "^4.2.2" } }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=" + }, "ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -484,6 +489,23 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "commander": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz", + "integrity": "sha1-Br42f+v9oMMwqh4qBy09yXYkJdQ=", + "requires": { + "graceful-readlink": ">= 1.0.0" + } + }, + "compressjs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/compressjs/-/compressjs-1.0.3.tgz", + "integrity": "sha1-ldt03VuQOM+AvKMhqw7eJxtJWbY=", + "requires": { + "amdefine": "~1.0.0", + "commander": "~2.8.1" + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -893,6 +915,11 @@ } } }, + "graceful-readlink": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", + "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=" + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1484,8 +1511,7 @@ "tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", - "dev": true + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" }, "tsutils": { "version": "3.21.0", diff --git a/package.json b/package.json index bc317a3..dc7340b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runejs/core", - "version": "1.5.4", + "version": "1.6.0", "description": "Core logging, networking, and buffer functionality for RuneJS applications.", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -30,9 +30,11 @@ }, "homepage": "https://github.com/runejs/core#readme", "dependencies": { + "compressjs": "^1.0.3", "js-yaml": "^3.14.1", "pino": "^6.13.0", - "pino-pretty": "^4.8.0" + "pino-pretty": "^4.8.0", + "tslib": ">=2.1.0" }, "devDependencies": { "@runejs/eslint-config": "^1.0.0", @@ -43,7 +45,6 @@ "eslint": "^7.32.0", "rimraf": "^3.0.2", "ts-node": "^9.1.1", - "tslib": ">=2.1.0", "typescript": ">=4.2.0" }, "peerDependencies": { diff --git a/src/compression/bzip2.ts b/src/compression/bzip2.ts new file mode 100644 index 0000000..d2a787b --- /dev/null +++ b/src/compression/bzip2.ts @@ -0,0 +1,28 @@ +import { ByteBuffer } from '../buffer'; +import * as compressjs from 'compressjs'; +const bzip = compressjs.Bzip2; + + +const charCode = (letter: string) => letter.charCodeAt(0); + + +export class Bzip2 { + + public static compress(rawFileData: ByteBuffer): ByteBuffer { + const compressedFile = new ByteBuffer(bzip.compressFile(rawFileData, undefined, 1)); + // Do not include the BZip compression level header because the client expects a headerless BZip format + return new ByteBuffer(compressedFile.slice(4, compressedFile.length)); + } + + public static decompress(compressedFileData: ByteBuffer): ByteBuffer { + const buffer = Buffer.alloc(compressedFileData.length + 4); + compressedFileData.copy(buffer, 4); + buffer[0] = charCode('B'); + buffer[1] = charCode('Z'); + buffer[2] = charCode('h'); + buffer[3] = charCode('1'); + + return new ByteBuffer(bzip.decompressFile(buffer)); + } + +} diff --git a/src/compression/file-compression.ts b/src/compression/file-compression.ts new file mode 100644 index 0000000..ae66e39 --- /dev/null +++ b/src/compression/file-compression.ts @@ -0,0 +1,5 @@ +export enum Compression { + uncompressed = 0, + bzip = 1, + gzip = 2 +} diff --git a/src/compression/gzip.ts b/src/compression/gzip.ts new file mode 100644 index 0000000..421c285 --- /dev/null +++ b/src/compression/gzip.ts @@ -0,0 +1,15 @@ +import { gunzipSync, gzipSync } from 'zlib'; +import { ByteBuffer } from '../buffer'; + + +export class Gzip { + + public static compress(buffer: ByteBuffer): ByteBuffer { + return new ByteBuffer(gzipSync(buffer)); + } + + public static decompress(buffer: ByteBuffer): ByteBuffer { + return new ByteBuffer(gunzipSync(buffer)); + } + +} diff --git a/src/compression/index.ts b/src/compression/index.ts new file mode 100644 index 0000000..ddc54a5 --- /dev/null +++ b/src/compression/index.ts @@ -0,0 +1,3 @@ +export * from './bzip2'; +export * from './gzip'; +export * from './file-compression'; diff --git a/src/encryption/index.ts b/src/encryption/index.ts new file mode 100644 index 0000000..0233b1a --- /dev/null +++ b/src/encryption/index.ts @@ -0,0 +1 @@ +export * from './xtea'; diff --git a/src/encryption/xtea.ts b/src/encryption/xtea.ts new file mode 100644 index 0000000..d33c8db --- /dev/null +++ b/src/encryption/xtea.ts @@ -0,0 +1,153 @@ +import path from 'path'; +import fs from 'fs'; +import { logger } from '../logger'; +import { ByteBuffer } from '../buffer'; + + +export type XteaKey = [ number, number, number, number ]; + + +export interface XteaConfig { + archive: number; + group: number; + name_hash: number; + name: string; + mapsquare: number; + key: XteaKey; +} + + +export interface XteaKeys { + gameVersion: number; + key: XteaKey; +} + + +const toInt = value => value | 0; + + +export class Xtea { + + public static loadKeys(xteaConfigPath: string): Map { + if(!fs.existsSync(xteaConfigPath)) { + logger.error(`Error loading XTEA keys: ${xteaConfigPath} was not found.`); + return null; + } + + const stats = fs.statSync(xteaConfigPath); + + if(!stats.isDirectory()) { + logger.error(`Error loading XTEA keys: ${xteaConfigPath} is not a directory.`); + return null; + } + + const xteaKeys: Map = new Map(); + const xteaFileNames = fs.readdirSync(xteaConfigPath); + for(const fileName of xteaFileNames) { + try { + const gameVersionString = fileName.substring(0, fileName.indexOf('.json')); + if(!gameVersionString) { + logger.error(`Error loading XTEA config file ${fileName}: No game version supplied.`); + continue; + } + + const gameVersion: number = Number(gameVersionString); + if(!gameVersion || isNaN(gameVersion)) { + logger.error(`Error loading XTEA config file ${fileName}: Invalid game version supplied.`); + continue; + } + + const fileContent = fs.readFileSync(path.join(xteaConfigPath, fileName), 'utf-8'); + const xteaConfigList = JSON.parse(fileContent) as XteaConfig[]; + + if(!xteaConfigList?.length) { + logger.error(`Error loading XTEA config file ${fileName}: File is empty.`); + continue; + } + + for(const xteaConfig of xteaConfigList) { + if(!xteaConfig?.name || !xteaConfig?.key?.length) { + continue; + } + + const { name: fileName, key } = xteaConfig; + let fileKeys: XteaKeys[] = []; + + if(xteaKeys.has(fileName)) { + fileKeys = xteaKeys.get(fileName); + } + + fileKeys.push({ gameVersion, key }); + xteaKeys.set(fileName, fileKeys); + } + } catch(error) { + logger.error(`Error loading XTEA config file ${fileName}:`, error); + } + } + + return xteaKeys; + } + + public static validKeys(keys?: number[] | undefined): boolean { + return keys?.length === 4 && (keys[0] !== 0 || keys[1] !== 0 || keys[2] !== 0 || keys[3] !== 0); + } + + // @TODO unit testing + public static encrypt(input: ByteBuffer, keys: number[], length: number): ByteBuffer { + const encryptedBuffer = new ByteBuffer(length); + const chunks = length / 8; + input.readerIndex = 0; + + for(let i = 0; i < chunks; i++) { + let v0 = input.get('int'); + let v1 = input.get('int'); + let sum = 0; + const delta = -0x61c88647; + + let rounds = 32; + while(rounds-- > 0) { + v0 += ((sum + keys[sum & 3]) ^ (v1 + ((v1 >>> 5) ^ (v1 << 4)))); + sum += delta + v1 += ((v0 + ((v0 >>> 5) ^ (v0 << 4))) ^ (keys[(sum >>> 11) & 3] + sum)); + } + + encryptedBuffer.put(v0, 'int'); + encryptedBuffer.put(v1, 'int'); + } + + return encryptedBuffer.flipWriter(); + } + + // @TODO unit testing + public static decrypt(input: ByteBuffer, keys: number[], length: number): ByteBuffer { + if(!keys?.length) { + return input; + } + + const output = new ByteBuffer(length); + const numBlocks = Math.floor(length / 8); + + for(let block = 0; block < numBlocks; block++) { + let v0 = input.get('int'); + let v1 = input.get('int'); + let sum = 0x9E3779B9 * 32; + + for(let i = 0; i < 32; i++) { + v1 -= ((toInt(v0 << 4) ^ toInt(v0 >>> 5)) + v0) ^ (sum + keys[(sum >>> 11) & 3]); + v1 = toInt(v1); + + sum -= 0x9E3779B9; + + v0 -= ((toInt(v1 << 4) ^ toInt(v1 >>> 5)) + v1) ^ (sum + keys[sum & 3]); + v0 = toInt(v0); + } + + output.put(v0, 'int'); + output.put(v1, 'int'); + } + + input.copy(output, output.writerIndex, input.readerIndex); + return output; + } + +}