@lievclouds/dsh-cortex 0.4.4 → 1.0.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.
Potentially problematic release.
This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.
- package/README.md +1 -1
- package/client.js +428 -406
- package/index.js +7 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +115 -106
- package/src/cortex-ui/dsh/api.ts +5 -2
- package/src/cortex-ui/dsh/css-overrides.css +8 -0
- package/src/cortex-ui/renderer/App.tsx +39 -0
- package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
- package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
- package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
- package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
- package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
- package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
- package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
- package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
- package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
- package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
- package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
- package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
- package/src/cortex-ui/renderer/lib/curl.ts +635 -0
- package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
- package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
- package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
- package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
- package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
- package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
- package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
- package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
- package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
- package/src/cortex-ui/shared/ipc.ts +3 -4
- package/src/cortex-ui/shared/types.ts +9 -0
- package/src/host/cortex/fs.js +72 -0
- package/src/host/cortex/index.js +49 -1
- package/src/host/cortex/repeater-store.js +411 -0
- package/src/host/cortex/repeater.js +15 -2
- package/src/host/cortex/settings.js +54 -0
- package/src/host/cortex/tools.js +2 -0
- package/src/host/cortex/vulns.js +36 -13
- package/src/host/miniapp/analyze.js +1078 -0
- package/src/host/miniapp/assets-import.js +294 -0
- package/src/host/miniapp/detect.js +426 -0
- package/src/host/miniapp/format.js +263 -0
- package/src/host/miniapp/roots.js +360 -0
- package/src/host/miniapp/routes.js +304 -0
- package/src/host/miniapp/service.js +559 -0
- package/src/host/miniapp/store.js +756 -0
- package/src/host/miniapp/tools.js +335 -0
- package/src/host/miniapp/unpack.js +1010 -0
- package/src/host/miniapp/wxapkg.js +416 -0
- package/src/host/reports-office.js +341 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wxapkg container reader and classic `V1MMWX` decryptor.
|
|
3
|
+
*
|
|
4
|
+
* WeChat desktop stores mini-program packages as `*.wxapkg` files. Two formats
|
|
5
|
+
* show up locally:
|
|
6
|
+
*
|
|
7
|
+
* - `plain` byte 0 is `0xBE`; the index/table is in clear text.
|
|
8
|
+
* - `v1mmwx` 6-byte magic `V1MMWX`; the body is AES-256-CBC encrypted with
|
|
9
|
+
* a key derived from the appid (see `decryptV1MMWX`).
|
|
10
|
+
*
|
|
11
|
+
* The decryption algorithm below is the classic PC WeChat scheme verified
|
|
12
|
+
* against local WeChat 4.x samples (36/37; the remaining `publicLib` package
|
|
13
|
+
* uses an unknown key and therefore degrades to `unsupported`, never crashes).
|
|
14
|
+
*
|
|
15
|
+
* Newer client versions are expected to change the header/key derivation. Keep
|
|
16
|
+
* the tiny decryptor registry (`registerDecryptor`) so a future scheme can be
|
|
17
|
+
* appended without touching callers.
|
|
18
|
+
*
|
|
19
|
+
* This module only reads bytes; it never writes or executes package content.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createCipheriv, createDecipheriv, pbkdf2Sync } from 'node:crypto'
|
|
23
|
+
import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs'
|
|
24
|
+
|
|
25
|
+
/** Encrypted package magic. */
|
|
26
|
+
export const WXAPKG_HEADER = 'V1MMWX'
|
|
27
|
+
|
|
28
|
+
/** Error codes surfaced to the unpack pipeline. */
|
|
29
|
+
export const ERROR_CODES = Object.freeze({
|
|
30
|
+
UNSUPPORTED_FORMAT: 'UNSUPPORTED_FORMAT',
|
|
31
|
+
DECRYPT_FAILED: 'DECRYPT_FAILED',
|
|
32
|
+
INVALID_PACKAGE: 'INVALID_PACKAGE',
|
|
33
|
+
LIMIT_EXCEEDED: 'LIMIT_EXCEEDED',
|
|
34
|
+
/** Path-jail violation (HTTP/workspace browsing, not package content). */
|
|
35
|
+
INVALID_PATH: 'INVALID_PATH',
|
|
36
|
+
/** Malformed search query (bad regular expression). */
|
|
37
|
+
INVALID_QUERY: 'INVALID_QUERY',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** One `WxapkgError` carries a stable `code` so callers can map to statuses. */
|
|
41
|
+
export class WxapkgError extends Error {
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} code one of `ERROR_CODES`
|
|
44
|
+
* @param {string} message human-readable detail
|
|
45
|
+
* @param {object} [details] optional diagnostic payload (paths, sizes, ...)
|
|
46
|
+
*/
|
|
47
|
+
constructor(code, message, details = undefined) {
|
|
48
|
+
super(message)
|
|
49
|
+
this.name = 'WxapkgError'
|
|
50
|
+
this.code = code
|
|
51
|
+
if (details !== undefined) this.details = details
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const HEADER_BYTES = WXAPKG_HEADER.length
|
|
56
|
+
const PLAIN_MAGIC = 0xbe
|
|
57
|
+
const PLAIN_INDEX_MARK = 0xed
|
|
58
|
+
const PLAIN_HEADER_BYTES = 18
|
|
59
|
+
const MAX_FILE_COUNT = 20000
|
|
60
|
+
const MAX_NAME_BYTES = 4096
|
|
61
|
+
|
|
62
|
+
const V1MMWX_SALT = 'saltiest'
|
|
63
|
+
const V1MMWX_ITERATIONS = 1000
|
|
64
|
+
const V1MMWX_KEY_BYTES = 32
|
|
65
|
+
/** 6-byte header + one 1024-byte AES block. */
|
|
66
|
+
const V1MMWX_MIN_BYTES = HEADER_BYTES + 1024
|
|
67
|
+
/** First 1023 plaintext bytes are carried inside the encrypted block. */
|
|
68
|
+
const V1MMWX_PLAIN_HEAD_BYTES = 1023
|
|
69
|
+
const V1MMWX_IV = Buffer.from('the iv: 16 bytes', 'ascii')
|
|
70
|
+
/** Fallback XOR byte when the appid is too short. */
|
|
71
|
+
const V1MMWX_FALLBACK_XOR = 0x66
|
|
72
|
+
|
|
73
|
+
function asBuffer(value) {
|
|
74
|
+
if (Buffer.isBuffer(value)) return value
|
|
75
|
+
if (value instanceof Uint8Array) return Buffer.from(value)
|
|
76
|
+
throw new TypeError('wxapkg buffer must be a Buffer or Uint8Array')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function detectFormatFromBytes(bytes) {
|
|
80
|
+
if (bytes.length >= HEADER_BYTES && bytes.subarray(0, HEADER_BYTES).toString('latin1') === WXAPKG_HEADER) {
|
|
81
|
+
return 'v1mmwx'
|
|
82
|
+
}
|
|
83
|
+
if (bytes.length >= 1 && bytes[0] === PLAIN_MAGIC) return 'plain'
|
|
84
|
+
return 'unknown'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** PBKDF2-HMAC-SHA1 key for the classic V1MMWX scheme. */
|
|
88
|
+
function v1mmwxKey(appid) {
|
|
89
|
+
const passphrase = typeof appid === 'string' ? appid : ''
|
|
90
|
+
if (passphrase.length === 0) {
|
|
91
|
+
throw new WxapkgError(ERROR_CODES.DECRYPT_FAILED, 'appid is required to decrypt a V1MMWX wxapkg')
|
|
92
|
+
}
|
|
93
|
+
return pbkdf2Sync(passphrase, V1MMWX_SALT, V1MMWX_ITERATIONS, V1MMWX_KEY_BYTES, 'sha1')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** XOR byte derived from the appid's second-to-last character. */
|
|
97
|
+
function v1mmwxXorByte(appid) {
|
|
98
|
+
const passphrase = typeof appid === 'string' ? appid : ''
|
|
99
|
+
if (passphrase.length < 2) return V1MMWX_FALLBACK_XOR
|
|
100
|
+
const code = passphrase.charCodeAt(passphrase.length - 2)
|
|
101
|
+
return Number.isFinite(code) ? code : V1MMWX_FALLBACK_XOR
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function xorBytes(buffer, keyByte) {
|
|
105
|
+
const out = Buffer.allocUnsafe(buffer.length)
|
|
106
|
+
for (let index = 0; index < buffer.length; index += 1) out[index] = buffer[index] ^ keyByte
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read package metadata without loading the whole file.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} filePath
|
|
114
|
+
* @returns {{format:'plain'|'v1mmwx'|'unknown', size:number, headerBytes:Buffer}}
|
|
115
|
+
*/
|
|
116
|
+
export function inspectPackage(filePath) {
|
|
117
|
+
const stats = statSync(filePath)
|
|
118
|
+
const length = Math.min(HEADER_BYTES, stats.size)
|
|
119
|
+
const headerBytes = Buffer.alloc(length)
|
|
120
|
+
if (length > 0) {
|
|
121
|
+
const fd = openSync(filePath, 'r')
|
|
122
|
+
try {
|
|
123
|
+
readSync(fd, headerBytes, 0, length, 0)
|
|
124
|
+
} finally {
|
|
125
|
+
closeSync(fd)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { format: detectFormatFromBytes(headerBytes), size: stats.size, headerBytes }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decrypt a classic `V1MMWX` package.
|
|
133
|
+
*
|
|
134
|
+
* ```
|
|
135
|
+
* key = PBKDF2-HMAC-SHA1(appid, 'saltiest', 1000, 32)
|
|
136
|
+
* iv = 'the iv: 16 bytes'
|
|
137
|
+
* head = AES-256-CBC-decrypt(file[6..1030]) (no auto padding)
|
|
138
|
+
* tail = file[1030..] XOR appid[len-2] (or 0x66)
|
|
139
|
+
* plain = head[0..1023] + tail
|
|
140
|
+
* ```
|
|
141
|
+
*
|
|
142
|
+
* @param {Buffer|Uint8Array} buffer encrypted package bytes
|
|
143
|
+
* @param {string} appid mini-program appid used as the PBKDF2 passphrase
|
|
144
|
+
* @returns {Buffer} the decrypted plain wxapkg bytes
|
|
145
|
+
* @throws {WxapkgError} code `DECRYPT_FAILED` when the input is too short, the
|
|
146
|
+
* header is wrong, or the key is wrong (no `0xBE` magic after decrypt).
|
|
147
|
+
*/
|
|
148
|
+
export function decryptV1MMWX(buffer, appid) {
|
|
149
|
+
const source = asBuffer(buffer)
|
|
150
|
+
if (source.length < V1MMWX_MIN_BYTES) {
|
|
151
|
+
throw new WxapkgError(ERROR_CODES.DECRYPT_FAILED, 'encrypted wxapkg is truncated', {
|
|
152
|
+
size: source.length,
|
|
153
|
+
minSize: V1MMWX_MIN_BYTES,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
if (source.subarray(0, HEADER_BYTES).toString('latin1') !== WXAPKG_HEADER) {
|
|
157
|
+
throw new WxapkgError(ERROR_CODES.DECRYPT_FAILED, 'not a V1MMWX wxapkg (unexpected header)')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const key = v1mmwxKey(appid)
|
|
161
|
+
let head
|
|
162
|
+
try {
|
|
163
|
+
const decipher = createDecipheriv('aes-256-cbc', key, V1MMWX_IV)
|
|
164
|
+
decipher.setAutoPadding(false)
|
|
165
|
+
head = Buffer.concat([
|
|
166
|
+
decipher.update(source.subarray(HEADER_BYTES, V1MMWX_MIN_BYTES)),
|
|
167
|
+
decipher.final(),
|
|
168
|
+
])
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new WxapkgError(ERROR_CODES.DECRYPT_FAILED, `V1MMWX AES decrypt failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
171
|
+
}
|
|
172
|
+
if (head.length < 1024) {
|
|
173
|
+
throw new WxapkgError(ERROR_CODES.DECRYPT_FAILED, 'V1MMWX decrypt produced a short block')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const tail = xorBytes(source.subarray(V1MMWX_MIN_BYTES), v1mmwxXorByte(appid))
|
|
177
|
+
const plain = Buffer.concat([head.subarray(0, V1MMWX_PLAIN_HEAD_BYTES), tail])
|
|
178
|
+
if (plain.length === 0 || plain[0] !== PLAIN_MAGIC) {
|
|
179
|
+
throw new WxapkgError(
|
|
180
|
+
ERROR_CODES.DECRYPT_FAILED,
|
|
181
|
+
'decrypted wxapkg has no 0xBE magic: wrong appid or unsupported client version',
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return plain
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Inverse of {@link decryptV1MMWX} — used by round-trip tests.
|
|
189
|
+
*
|
|
190
|
+
* The first 1023 plaintext bytes are padded with one zero byte, encrypted as a
|
|
191
|
+
* single 1024-byte AES block; the remainder is XOR-obfuscated. The synthetic
|
|
192
|
+
* zero byte is not part of the plaintext so the round trip is byte-exact.
|
|
193
|
+
*
|
|
194
|
+
* @param {Buffer|Uint8Array} buffer plain wxapkg bytes (>= 1023 bytes)
|
|
195
|
+
* @param {string} appid
|
|
196
|
+
* @returns {Buffer}
|
|
197
|
+
* @throws {WxapkgError} code `INVALID_PACKAGE` when the plaintext is too short
|
|
198
|
+
*/
|
|
199
|
+
export function encryptV1MMWX(buffer, appid) {
|
|
200
|
+
const plain = asBuffer(buffer)
|
|
201
|
+
if (plain.length < V1MMWX_PLAIN_HEAD_BYTES) {
|
|
202
|
+
throw new WxapkgError(
|
|
203
|
+
ERROR_CODES.INVALID_PACKAGE,
|
|
204
|
+
`plain wxapkg is too short to encrypt (${plain.length} < ${V1MMWX_PLAIN_HEAD_BYTES})`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
const key = v1mmwxKey(appid)
|
|
208
|
+
const block = Buffer.alloc(1024)
|
|
209
|
+
plain.copy(block, 0, 0, V1MMWX_PLAIN_HEAD_BYTES)
|
|
210
|
+
|
|
211
|
+
const cipher = createCipheriv('aes-256-cbc', key, V1MMWX_IV)
|
|
212
|
+
cipher.setAutoPadding(false)
|
|
213
|
+
const head = Buffer.concat([cipher.update(block), cipher.final()])
|
|
214
|
+
const tail = xorBytes(plain.subarray(V1MMWX_PLAIN_HEAD_BYTES), v1mmwxXorByte(appid))
|
|
215
|
+
return Buffer.concat([Buffer.from(WXAPKG_HEADER, 'latin1'), head, tail])
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Parse the plain wxapkg table.
|
|
220
|
+
*
|
|
221
|
+
* Layout:
|
|
222
|
+
* ```
|
|
223
|
+
* 0x00 0xBE
|
|
224
|
+
* 0x01 uint32BE info1
|
|
225
|
+
* 0x05 uint32BE indexLength
|
|
226
|
+
* 0x09 uint32BE bodyLength
|
|
227
|
+
* 0x0D 0xED
|
|
228
|
+
* 0x0E uint32BE fileCount
|
|
229
|
+
* 0x12 fileCount * { uint32BE nameLen, name(UTF-8), uint32BE offset, uint32BE size }
|
|
230
|
+
* ```
|
|
231
|
+
* Entry names are returned as stored (usually starting with `/`); use
|
|
232
|
+
* `safeRelativePath` from `unpack.js` before touching the filesystem.
|
|
233
|
+
*
|
|
234
|
+
* @param {Buffer|Uint8Array} buffer plain package bytes
|
|
235
|
+
* @returns {{format:'plain', files:Array<{name:string, offset:number, size:number}>, meta:{info1:number,indexLength:number,bodyLength:number,lastMark:number,fileCount:number}}}
|
|
236
|
+
* @throws {WxapkgError} `INVALID_PACKAGE` for structural corruption,
|
|
237
|
+
* `LIMIT_EXCEEDED` for absurd file counts / name lengths.
|
|
238
|
+
*/
|
|
239
|
+
export function parseWxapkg(buffer) {
|
|
240
|
+
const source = asBuffer(buffer)
|
|
241
|
+
if (source.length < PLAIN_HEADER_BYTES) {
|
|
242
|
+
throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, 'wxapkg is truncated: header needs 18 bytes', {
|
|
243
|
+
size: source.length,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
if (source[0] !== PLAIN_MAGIC) {
|
|
247
|
+
throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, 'not a plain wxapkg (missing 0xBE magic)')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const info1 = source.readUInt32BE(1)
|
|
251
|
+
const indexLength = source.readUInt32BE(5)
|
|
252
|
+
const bodyLength = source.readUInt32BE(9)
|
|
253
|
+
const lastMark = source[13]
|
|
254
|
+
if (lastMark !== PLAIN_INDEX_MARK) {
|
|
255
|
+
throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, 'not a plain wxapkg (missing 0xED index mark)')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const fileCount = source.readUInt32BE(14)
|
|
259
|
+
if (fileCount > MAX_FILE_COUNT) {
|
|
260
|
+
throw new WxapkgError(ERROR_CODES.LIMIT_EXCEEDED, `wxapkg declares too many files (${fileCount} > ${MAX_FILE_COUNT})`, {
|
|
261
|
+
fileCount,
|
|
262
|
+
max: MAX_FILE_COUNT,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
if (indexLength > 0 && PLAIN_HEADER_BYTES + indexLength > source.length) {
|
|
266
|
+
throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, 'wxapkg file index exceeds the package size', {
|
|
267
|
+
indexLength,
|
|
268
|
+
size: source.length,
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const files = []
|
|
273
|
+
let cursor = PLAIN_HEADER_BYTES
|
|
274
|
+
for (let index = 0; index < fileCount; index += 1) {
|
|
275
|
+
if (cursor + 4 > source.length) {
|
|
276
|
+
throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `wxapkg file index is truncated at entry ${index}`)
|
|
277
|
+
}
|
|
278
|
+
const nameLength = source.readUInt32BE(cursor)
|
|
279
|
+
cursor += 4
|
|
280
|
+
if (nameLength > MAX_NAME_BYTES) {
|
|
281
|
+
throw new WxapkgError(ERROR_CODES.LIMIT_EXCEEDED, `wxapkg entry ${index} name is too long (${nameLength})`, {
|
|
282
|
+
max: MAX_NAME_BYTES,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
if (cursor + nameLength + 8 > source.length) {
|
|
286
|
+
throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `wxapkg file index is truncated at entry ${index}`)
|
|
287
|
+
}
|
|
288
|
+
const name = source.subarray(cursor, cursor + nameLength).toString('utf8')
|
|
289
|
+
cursor += nameLength
|
|
290
|
+
const offset = source.readUInt32BE(cursor)
|
|
291
|
+
cursor += 4
|
|
292
|
+
const size = source.readUInt32BE(cursor)
|
|
293
|
+
cursor += 4
|
|
294
|
+
if (offset > source.length || size > source.length - offset) {
|
|
295
|
+
throw new WxapkgError(
|
|
296
|
+
ERROR_CODES.INVALID_PACKAGE,
|
|
297
|
+
`wxapkg entry ${JSON.stringify(name)} points outside the package`,
|
|
298
|
+
{ offset, size, packageSize: source.length },
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
files.push({ name, offset, size })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
format: 'plain',
|
|
306
|
+
files,
|
|
307
|
+
meta: { info1, indexLength, bodyLength, lastMark, fileCount },
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* ------------------------- decryptor registry ------------------------- */
|
|
312
|
+
|
|
313
|
+
/** @type {Array<{id:string, header?:string, detect:(buffer:Buffer)=>boolean, decrypt:(buffer:Buffer, appid:string)=>Buffer}>} */
|
|
314
|
+
const decryptors = []
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Append a decryptor for a future client version. `detect` receives the raw
|
|
318
|
+
* package buffer and must return `true` for its format; `decrypt` returns the
|
|
319
|
+
* plain package bytes (starting with `0xBE`).
|
|
320
|
+
*/
|
|
321
|
+
export function registerDecryptor(decryptor) {
|
|
322
|
+
if (
|
|
323
|
+
!decryptor ||
|
|
324
|
+
typeof decryptor.id !== 'string' ||
|
|
325
|
+
decryptor.id.length === 0 ||
|
|
326
|
+
typeof decryptor.detect !== 'function' ||
|
|
327
|
+
typeof decryptor.decrypt !== 'function'
|
|
328
|
+
) {
|
|
329
|
+
throw new TypeError('decryptor must have { id, detect(buffer), decrypt(buffer, appid) }')
|
|
330
|
+
}
|
|
331
|
+
if (decryptors.some((entry) => entry.id === decryptor.id)) {
|
|
332
|
+
throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `decryptor already registered: ${decryptor.id}`)
|
|
333
|
+
}
|
|
334
|
+
decryptors.push(decryptor)
|
|
335
|
+
return decryptor
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Registered decryptor ids (no functions exposed). */
|
|
339
|
+
export function listDecryptors() {
|
|
340
|
+
return decryptors.map((entry) => ({ id: entry.id, header: entry.header || '' }))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function findDecryptor(buffer) {
|
|
344
|
+
for (const decryptor of decryptors) {
|
|
345
|
+
let matched = false
|
|
346
|
+
try {
|
|
347
|
+
matched = decryptor.detect(buffer) === true
|
|
348
|
+
} catch {
|
|
349
|
+
matched = false
|
|
350
|
+
}
|
|
351
|
+
if (matched) return decryptor
|
|
352
|
+
}
|
|
353
|
+
return null
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
registerDecryptor({
|
|
357
|
+
id: 'v1mmwx',
|
|
358
|
+
header: WXAPKG_HEADER,
|
|
359
|
+
detect: (buffer) =>
|
|
360
|
+
buffer.length >= HEADER_BYTES && buffer.subarray(0, HEADER_BYTES).toString('latin1') === WXAPKG_HEADER,
|
|
361
|
+
decrypt: (buffer, appid) => decryptV1MMWX(buffer, appid),
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
/* ------------------------------ readers ------------------------------ */
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Read + decrypt + parse a package that is already in memory.
|
|
368
|
+
*
|
|
369
|
+
* @param {Buffer|Uint8Array} buffer raw package bytes
|
|
370
|
+
* @param {{appid?:string, filePath?:string}} [options]
|
|
371
|
+
* @returns {{format:'plain'|'v1mmwx', buffer:Buffer, files:Array<{name:string,offset:number,size:number}>, meta:object}}
|
|
372
|
+
*/
|
|
373
|
+
export function readPackageBuffer(buffer, { appid = '', filePath = '' } = {}) {
|
|
374
|
+
const raw = asBuffer(buffer)
|
|
375
|
+
const decryptor = findDecryptor(raw)
|
|
376
|
+
let format
|
|
377
|
+
let plain
|
|
378
|
+
if (decryptor) {
|
|
379
|
+
format = decryptor.id
|
|
380
|
+
plain = decryptor.decrypt(raw, appid)
|
|
381
|
+
} else if (raw.length > 0 && raw[0] === PLAIN_MAGIC) {
|
|
382
|
+
format = 'plain'
|
|
383
|
+
plain = raw
|
|
384
|
+
} else {
|
|
385
|
+
const detected = detectFormatFromBytes(raw.subarray(0, HEADER_BYTES))
|
|
386
|
+
throw new WxapkgError(
|
|
387
|
+
ERROR_CODES.UNSUPPORTED_FORMAT,
|
|
388
|
+
`unsupported wxapkg format (${detected === 'unknown' ? 'unknown header' : detected})`,
|
|
389
|
+
{ filePath: filePath || '', header: raw.subarray(0, 8).toString('hex') },
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
const parsed = parseWxapkg(plain)
|
|
393
|
+
return { format, buffer: plain, files: parsed.files, meta: parsed.meta }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Read one package from disk.
|
|
398
|
+
*
|
|
399
|
+
* @param {string} filePath
|
|
400
|
+
* @param {{appid?:string}} [options]
|
|
401
|
+
* @returns {{format:'plain'|'v1mmwx', buffer:Buffer, files:Array<{name:string,offset:number,size:number}>, meta:object}}
|
|
402
|
+
* @throws {WxapkgError} `UNSUPPORTED_FORMAT` / `DECRYPT_FAILED` / `INVALID_PACKAGE`
|
|
403
|
+
*/
|
|
404
|
+
export function readPackage(filePath, { appid = '' } = {}) {
|
|
405
|
+
let raw
|
|
406
|
+
try {
|
|
407
|
+
raw = readFileSync(filePath)
|
|
408
|
+
} catch (error) {
|
|
409
|
+
throw new WxapkgError(
|
|
410
|
+
ERROR_CODES.INVALID_PACKAGE,
|
|
411
|
+
`cannot read wxapkg: ${error instanceof Error ? error.message : String(error)}`,
|
|
412
|
+
{ filePath },
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
return readPackageBuffer(raw, { appid, filePath })
|
|
416
|
+
}
|