titanium-apk-recover 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ import { DexFile, DexClassDef } from 'libdex-ts';
2
+
3
+ /**
4
+ * Public type definitions for ti_recover.
5
+ */
6
+ /** Configuration accepted by {@link TiRecover}. */
7
+ interface RecoverConfig {
8
+ /** Path to the `.apk` file to recover. Relative paths resolve against `cwd`. */
9
+ apk?: string;
10
+ /**
11
+ * Path to an already-extracted APK directory. When provided, the unzip step
12
+ * is skipped: `classes*.dex` and `AndroidManifest.xml` are read from here.
13
+ */
14
+ apkDir?: string;
15
+ /** Directory where recovered source and assets are written. */
16
+ outDir?: string;
17
+ /** Temporary directory used while unpacking the APK. Defaults to `_tmp`. */
18
+ tmpDir?: string;
19
+ /** Emit progress logging to the console. Defaults to `false`. */
20
+ debug?: boolean;
21
+ }
22
+ /** A single recovered source file held in memory. */
23
+ interface RecoveredFile {
24
+ /** Byte offset of the file inside the encrypted asset blob (0 for dev mode). */
25
+ offset: number;
26
+ /** Length in bytes of the recovered content. */
27
+ bytes: number;
28
+ /** Recovered file contents. */
29
+ content: string | Buffer;
30
+ }
31
+ /** Map of relative file path -> recovered file. */
32
+ type MemorySource = Record<string, RecoveredFile>;
33
+ /** Raw values parsed from a decoded `AndroidManifest.xml`. */
34
+ interface ManifestInfo {
35
+ package?: string;
36
+ versionCode?: string;
37
+ versionName?: string;
38
+ appName?: string;
39
+ /** Directory the manifest was read from. */
40
+ dir?: string;
41
+ }
42
+ /** Detected Titanium metadata, returned by {@link TiRecover.info}. */
43
+ interface TitaniumInfo extends ManifestInfo {
44
+ /** Whether the APK was built in Titanium "development" mode (plain sources). */
45
+ developmentMode: boolean;
46
+ /** Coarse Titanium engine version bucket: `"<5"`, `"5.x"` or `"unknown"`. */
47
+ titaniumVersion: TitaniumVersion;
48
+ /** Whether the project appears to be built with Alloy. */
49
+ alloy: boolean;
50
+ /** Recovered files and their sizes. */
51
+ files: {
52
+ name: string;
53
+ bytes: number;
54
+ }[];
55
+ /** Total number of recovered bytes across all files. */
56
+ totalBytes: number;
57
+ }
58
+ type TitaniumVersion = "<5" | "5.x" | "unknown";
59
+ /** Metadata collected while decrypting the asset blob. */
60
+ interface DecryptMeta {
61
+ totalBytes: number;
62
+ titaniumVersion: TitaniumVersion;
63
+ alloy: boolean;
64
+ }
65
+ /** Result of a decryption pass. */
66
+ interface DecryptResult {
67
+ files: MemorySource;
68
+ meta: DecryptMeta;
69
+ }
70
+
71
+ interface WrittenFile {
72
+ name: string;
73
+ bytes: number;
74
+ }
75
+
76
+ /**
77
+ * Parses a binary `AndroidManifest.xml` buffer, returning both structured info
78
+ * and a readable XML serialisation.
79
+ */
80
+ declare function parseBinaryManifest(bytes: Buffer, dir?: string): {
81
+ info: ManifestInfo;
82
+ xml: string;
83
+ };
84
+ /**
85
+ * Text manifest parser (cheerio) for pre-decoded, human-readable manifests.
86
+ */
87
+ declare function parseManifest(xml: string, dir?: string): ManifestInfo;
88
+
89
+ /**
90
+ * Extract Titanium's encrypted asset data directly from `classes*.dex`,
91
+ * replacing the apktool (smali) + jadx (java) decompile step.
92
+ *
93
+ * The generated `AssetCryptImpl` class has two private static methods:
94
+ * - `initAssetsBytes()` builds the encrypted blob by appending a sequence of
95
+ * `const-string` literals into a CharBuffer.
96
+ * - `initAssets()` fills a HashMap with `put("file", new Range(offset, length))`
97
+ * entries.
98
+ *
99
+ * Both are ordinary DEX bytecode. We locate the class, then walk each method's
100
+ * instruction stream: for the blob we collect `const-string`/`const-string/jumbo`
101
+ * operands in order; for the ranges we do a tiny register trace to recover the
102
+ * (file, offset, length) triples. A DEX instruction width table lets us step
103
+ * over every opcode correctly. Since DEX stores real (already-unescaped)
104
+ * strings, the old Java string-unescape step is gone entirely.
105
+ */
106
+
107
+ /** A single file's location inside the decrypted blob, from `initAssets`. */
108
+ interface DexRange {
109
+ file: string;
110
+ offset: number;
111
+ bytes: number;
112
+ }
113
+ interface AssetCrypt {
114
+ /** The raw encrypted asset blob (last 16 bytes are the AES key). */
115
+ blob: Buffer;
116
+ ranges: DexRange[];
117
+ titaniumVersion: TitaniumVersion;
118
+ }
119
+ type AssetCryptResult = {
120
+ kind: "classic";
121
+ data: AssetCrypt;
122
+ } | {
123
+ kind: "newscheme";
124
+ salt: Buffer | null;
125
+ } | {
126
+ kind: "none";
127
+ };
128
+ /** Width, in code units, of the instruction at `idx` (handles payloads). */
129
+ declare function instructionWidth(insns: Uint16Array, idx: number): number;
130
+ /** Collects `const-string` operands from a method in execution order. */
131
+ declare function extractStringChunks(dex: DexFile, insns: Uint16Array): string[];
132
+ /**
133
+ * Walks `initAssets` with a small register trace to recover the file ranges.
134
+ * Tracks integer/string constants per register, associates `new Range(off,len)`
135
+ * with the object register at its `<init>`, and emits an entry when that object
136
+ * is stored into the map via `put(key, rangeObj)`.
137
+ */
138
+ declare function extractRanges(dex: DexFile, insns: Uint16Array): DexRange[];
139
+ /**
140
+ * Extracts static `byte[]` fields (assigned via `fill-array-data` +
141
+ * `sput-object`) from a method's instruction stream, keyed by field name.
142
+ */
143
+ declare function extractByteArrayFields(dex: DexFile, insns: Uint16Array): Record<string, Buffer>;
144
+ /** Lifts the ti.cloak `salt` (AES-CBC IV) out of `AssetCryptImpl.<clinit>`. */
145
+ declare function extractCloakSalt(dex: DexFile, classDef: DexClassDef): Buffer | null;
146
+ /**
147
+ * Locates `AssetCryptImpl` across the given DEX buffers and extracts the
148
+ * encrypted blob plus file ranges. Returns `newscheme` when the class exists
149
+ * but lacks `initAssetsBytes` (the ti.cloak/.bin variant), or `none` when the
150
+ * class is absent entirely.
151
+ */
152
+ declare function readAssetCrypt(dexBuffers: Uint8Array[], packageHint?: string): AssetCryptResult;
153
+
154
+ /** A single file's location inside the decrypted asset blob. */
155
+ interface Range {
156
+ offset: number;
157
+ bytes: number;
158
+ }
159
+ /** Result of parsing `AssetCryptImpl.smali`'s `initAssetsBytes()`. */
160
+ interface AssetBufferParse {
161
+ titaniumVersion: TitaniumVersion;
162
+ bufferLen: number;
163
+ escaped: string;
164
+ }
165
+ /**
166
+ * Decodes an integer literal the way `java.lang.Integer.decode` does:
167
+ * supports optional sign, `0x`/`0X`/`#` hex, leading-zero octal, and decimal.
168
+ */
169
+ declare function decodeJavaInt(literal: string): number;
170
+ /**
171
+ * Parses the `hashMap.put("file", new Range(offset, length))` entries from an
172
+ * `AssetCryptImpl.java` source into an ordered map of file -> range.
173
+ */
174
+ declare function parseRanges(javaContent: string): Record<string, Range>;
175
+ /**
176
+ * Parses `AssetCryptImpl.smali`, extracting the declared buffer length, the
177
+ * concatenated escaped string literal and a coarse Titanium version.
178
+ */
179
+ declare function parseAssetBuffer(smaliContent: string): AssetBufferParse;
180
+ /** Heuristic: does the recovered file set look like an Alloy project? */
181
+ declare function detectAlloy(files: MemorySource): boolean;
182
+ /**
183
+ * Decrypts one `[offset, length]` slice of the blob. The key is the last 16
184
+ * bytes of the blob. Titanium varied two conventions across versions, so we try
185
+ * the key derived from both `length` and `length - 1`, and the slice from both
186
+ * `offset` and `offset - 1` (some files have a padded offset).
187
+ */
188
+ declare function decryptRange(blob: Buffer, offset: number, length: number): string | null;
189
+ interface DecryptRangesResult {
190
+ files: MemorySource;
191
+ totalBytes: number;
192
+ }
193
+ /**
194
+ * Decrypts every range out of the blob into an in-memory source map. Files that
195
+ * fail to decrypt are skipped rather than aborting the whole run.
196
+ */
197
+ declare function decryptRanges(blob: Buffer, ranges: DexRange[], debug?: boolean): DecryptRangesResult;
198
+
199
+ /**
200
+ * Derives the AES key from a `libti.cloak.so` buffer and the `salt`.
201
+ * Returns `null` if the buffer is too small or the salt is not 16 bytes.
202
+ */
203
+ declare function deriveCloakKey(so: Buffer, salt: Buffer): Buffer | null;
204
+ /**
205
+ * Decrypts one `.bin` asset with AES-128-CBC (IV = salt), transparently
206
+ * gunzipping the result when it is gzip-compressed. Returns `null` on failure.
207
+ */
208
+ declare function decryptCloakAsset(bin: Buffer, key: Buffer, salt: Buffer): Buffer | null;
209
+ /** Heuristic: does this buffer look like decoded UTF-8 text (e.g. JS source)? */
210
+ declare function isProbablyText(buf: Buffer): boolean;
211
+ /**
212
+ * Tries each `libti.cloak.so` buffer, derives a candidate key and confirms it
213
+ * by decrypting `sampleBin` into readable text. Returns the first working key,
214
+ * or `null` if none produce a valid decryption.
215
+ */
216
+ declare function pickCloakKey(cloakLibs: Buffer[], salt: Buffer, sampleBin: Buffer): Buffer | null;
217
+
218
+ interface BuildInfoInput {
219
+ manifest: ManifestInfo | null;
220
+ memorySource: MemorySource;
221
+ meta?: DecryptMeta;
222
+ developmentMode: boolean;
223
+ }
224
+ /**
225
+ * Combines manifest fields, decryption metadata and the recovered file list
226
+ * into the public {@link TitaniumInfo} shape returned by `TiRecover.info()`.
227
+ */
228
+ declare function buildInfo(input: BuildInfoInput): TitaniumInfo;
229
+
230
+ interface ReconstructResult {
231
+ /** Remapped sources, everything nested under `Resources/`, plus `tiapp.xml`. */
232
+ memorySource: MemorySource;
233
+ /** The generated tiapp.xml content. */
234
+ tiappXml: string;
235
+ /** Whether the source appears to be an Alloy project. */
236
+ alloy: boolean;
237
+ /** Always true; signals that assets should be nested under `Resources/`. */
238
+ restructured: true;
239
+ }
240
+ /**
241
+ * Produces a reconstructed, Titanium-openable project from recovered sources.
242
+ */
243
+ declare function reconstruct(memorySource: MemorySource, info: TitaniumInfo): ReconstructResult;
244
+ /** Builds a minimal but valid classic `tiapp.xml` from recovered metadata. */
245
+ declare function buildTiappXml(info: TitaniumInfo): string;
246
+
247
+ /**
248
+ * Thrown when an APK uses Titanium's ti.cloak (.bin) encryption and recovery
249
+ * could not complete — typically because the hardcoded `salt` or the bundled
250
+ * `libti.cloak.so` (needed to derive the AES key) is missing.
251
+ */
252
+ declare class UnsupportedEncryptionError extends Error {
253
+ constructor(reason?: string);
254
+ }
255
+ /**
256
+ * Orchestrates recovery of a Titanium APK: unpack, detect, decrypt/extract,
257
+ * optionally reconstruct a project, and write everything to disk.
258
+ */
259
+ declare class TiRecover {
260
+ private readonly cwd;
261
+ private config;
262
+ private apkDir;
263
+ private tmpUsed;
264
+ private manifest;
265
+ private dexBuffers;
266
+ private cloakLibs;
267
+ private assetCrypt;
268
+ private developmentMode;
269
+ private tested;
270
+ private isTitanium;
271
+ private memorySource;
272
+ private meta?;
273
+ private restructured;
274
+ constructor(config?: RecoverConfig);
275
+ /** Absolute output directory (resolved against cwd for relative paths). */
276
+ private get outDir();
277
+ private log;
278
+ /**
279
+ * Prepares the working directory. If `apkDir` is supplied it is used directly
280
+ * (reading any `classes*.dex` and manifest found there); otherwise the
281
+ * configured `apk` is unzipped into `tmpDir`.
282
+ */
283
+ init(): Promise<void>;
284
+ /**
285
+ * Returns `true` if the prepared APK was built with Titanium (in either
286
+ * distribution or development mode), populating internal detection state.
287
+ */
288
+ test(): Promise<boolean>;
289
+ /**
290
+ * Extracts recovered sources into memory. For distribution builds this
291
+ * decrypts the AES asset blob (from data lifted out of the DEX); for
292
+ * development builds it reads the plain `assets/Resources` tree.
293
+ */
294
+ extract(): Promise<MemorySource>;
295
+ /**
296
+ * Attempts ti.cloak (.bin) recovery: derive the AES key from a bundled
297
+ * `libti.cloak.so` + the `salt` lifted from `AssetCryptImpl`, then AES-128-CBC
298
+ * decrypt every `Resources/*.bin` asset. Returns the recovered sources, or
299
+ * `null` when the salt/native lib is missing or no key validates.
300
+ */
301
+ private recoverCloak;
302
+ /**
303
+ * Returns Titanium metadata for the current APK. Must be called after
304
+ * {@link extract} to include the recovered file list.
305
+ */
306
+ info(): Promise<TitaniumInfo>;
307
+ /**
308
+ * Rebuilds the in-memory sources into a Titanium-openable project structure
309
+ * (everything under `Resources/`, plus a synthesised `tiapp.xml`).
310
+ */
311
+ reconstruct(): Promise<MemorySource>;
312
+ /** Writes the in-memory sources to the configured `outDir`. */
313
+ writeToDisk(): Promise<WrittenFile[]>;
314
+ /** Copies the APK's static assets and manifest into `outDir`. */
315
+ copyAssets(): Promise<void>;
316
+ /** Removes the temporary directory created during {@link init}. */
317
+ clean(): Promise<void>;
318
+ }
319
+ interface RecoverOptions extends RecoverConfig {
320
+ /** Reconstruct an openable Titanium project layout. Defaults to `false`. */
321
+ reconstruct?: boolean;
322
+ /** Remove the temporary working directory when finished. Defaults to `true`. */
323
+ clean?: boolean;
324
+ }
325
+ interface RecoverResult {
326
+ /** Whether the APK was recognised as a Titanium app and recovered. */
327
+ recovered: boolean;
328
+ /** Titanium metadata, present when `recovered` is true. */
329
+ info?: TitaniumInfo;
330
+ /** Files written to disk, present when `recovered` is true. */
331
+ files?: WrittenFile[];
332
+ }
333
+ /**
334
+ * One-shot recovery: unpack, detect, extract, optionally reconstruct, write to
335
+ * disk, copy assets and clean up. Returns whether recovery succeeded plus the
336
+ * gathered info.
337
+ */
338
+ declare function recover(options: RecoverOptions): Promise<RecoverResult>;
339
+
340
+ export { type AssetCrypt, type AssetCryptResult, type DecryptMeta, type DecryptResult, type DexRange, type ManifestInfo, type MemorySource, type Range, type RecoverConfig, type RecoverOptions, type RecoverResult, type RecoveredFile, TiRecover, type TitaniumInfo, type TitaniumVersion, UnsupportedEncryptionError, type WrittenFile, buildInfo, reconstruct as buildReconstruct, buildTiappXml, decodeJavaInt, decryptCloakAsset, decryptRange, decryptRanges, deriveCloakKey, detectAlloy, extractByteArrayFields, extractCloakSalt, extractRanges, extractStringChunks, instructionWidth, isProbablyText, parseAssetBuffer, parseBinaryManifest, parseManifest, parseRanges, pickCloakKey, readAssetCrypt, recover };
package/dist/index.js ADDED
@@ -0,0 +1,53 @@
1
+ import {
2
+ TiRecover,
3
+ UnsupportedEncryptionError,
4
+ buildInfo,
5
+ buildTiappXml,
6
+ decodeJavaInt,
7
+ decryptCloakAsset,
8
+ decryptRange,
9
+ decryptRanges,
10
+ deriveCloakKey,
11
+ detectAlloy,
12
+ extractByteArrayFields,
13
+ extractCloakSalt,
14
+ extractRanges,
15
+ extractStringChunks,
16
+ instructionWidth,
17
+ isProbablyText,
18
+ parseAssetBuffer,
19
+ parseBinaryManifest,
20
+ parseManifest,
21
+ parseRanges,
22
+ pickCloakKey,
23
+ readAssetCrypt,
24
+ reconstruct,
25
+ recover
26
+ } from "./chunk-ML2DCRT6.js";
27
+ export {
28
+ TiRecover,
29
+ UnsupportedEncryptionError,
30
+ buildInfo,
31
+ reconstruct as buildReconstruct,
32
+ buildTiappXml,
33
+ decodeJavaInt,
34
+ decryptCloakAsset,
35
+ decryptRange,
36
+ decryptRanges,
37
+ deriveCloakKey,
38
+ detectAlloy,
39
+ extractByteArrayFields,
40
+ extractCloakSalt,
41
+ extractRanges,
42
+ extractStringChunks,
43
+ instructionWidth,
44
+ isProbablyText,
45
+ parseAssetBuffer,
46
+ parseBinaryManifest,
47
+ parseManifest,
48
+ parseRanges,
49
+ pickCloakKey,
50
+ readAssetCrypt,
51
+ recover
52
+ };
53
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "titanium-apk-recover",
3
+ "version": "2.2.0",
4
+ "description": "Recover the source code from almost any APK made with Appcelerator Titanium (development, distribution or ti.cloak mode).",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "bin": {
17
+ "titanium-apk-recover": "./dist/cli.js",
18
+ "ti_recover": "./dist/cli.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "scripts": {
28
+ "build": "tsup",
29
+ "dev": "tsup --watch",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest",
33
+ "lint": "eslint .",
34
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
35
+ "prepublishOnly": "npm run build"
36
+ },
37
+ "keywords": [
38
+ "appcelerator",
39
+ "titanium",
40
+ "apk",
41
+ "android",
42
+ "recover",
43
+ "source",
44
+ "decompile",
45
+ "decompiler",
46
+ "unpack",
47
+ "extract",
48
+ "ti.cloak",
49
+ "reverse-engineering",
50
+ "cli"
51
+ ],
52
+ "author": "Pablo Schaffner",
53
+ "license": "Apache-2.0",
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/puntorigen/ti_recover.git"
57
+ },
58
+ "bugs": {
59
+ "url": "https://github.com/puntorigen/ti_recover/issues"
60
+ },
61
+ "homepage": "https://github.com/puntorigen/ti_recover#readme",
62
+ "dependencies": {
63
+ "adbkit-apkreader": "^3.2.0",
64
+ "cheerio": "^1.1.2",
65
+ "commander": "^15.0.0",
66
+ "fflate": "^0.8.3",
67
+ "js-beautify": "^2.0.3",
68
+ "libdex-ts": "^1.0.1",
69
+ "picocolors": "^1.1.1"
70
+ },
71
+ "devDependencies": {
72
+ "@eslint/js": "^10.0.1",
73
+ "@types/js-beautify": "^1.14.3",
74
+ "@types/node": "^26.1.0",
75
+ "eslint": "^10.6.0",
76
+ "prettier": "^3.9.4",
77
+ "tsup": "^8.5.1",
78
+ "typescript": "^6.0.3",
79
+ "typescript-eslint": "^8.63.0",
80
+ "vitest": "^4.1.10"
81
+ }
82
+ }