@metamask/device-mcp 0.3.2 → 0.4.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.
- package/CHANGELOG.md +18 -1
- package/README.md +110 -21
- package/dist/android/NOTICE.md +65 -0
- package/dist/android/device-mcp-android-snapshot-helper-0.4.0.apk +0 -0
- package/dist/android/device-mcp-android-snapshot-helper-0.4.0.apk.idsig +0 -0
- package/dist/android/device-mcp-android-snapshot-helper-0.4.0.manifest.json +10 -0
- package/dist/backends/adb-backend.cjs +326 -17
- package/dist/backends/adb-backend.cjs.map +1 -1
- package/dist/backends/adb-backend.d.cts +1 -0
- package/dist/backends/adb-backend.d.cts.map +1 -1
- package/dist/backends/adb-backend.d.mts +1 -0
- package/dist/backends/adb-backend.d.mts.map +1 -1
- package/dist/backends/adb-backend.mjs +327 -18
- package/dist/backends/adb-backend.mjs.map +1 -1
- package/dist/backends/android-instrumentation/adb.cjs +28 -0
- package/dist/backends/android-instrumentation/adb.cjs.map +1 -0
- package/dist/backends/android-instrumentation/adb.d.cts +24 -0
- package/dist/backends/android-instrumentation/adb.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/adb.d.mts +24 -0
- package/dist/backends/android-instrumentation/adb.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/adb.mjs +25 -0
- package/dist/backends/android-instrumentation/adb.mjs.map +1 -0
- package/dist/backends/android-instrumentation/artifact.cjs +139 -0
- package/dist/backends/android-instrumentation/artifact.cjs.map +1 -0
- package/dist/backends/android-instrumentation/artifact.d.cts +28 -0
- package/dist/backends/android-instrumentation/artifact.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/artifact.d.mts +28 -0
- package/dist/backends/android-instrumentation/artifact.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/artifact.mjs +154 -0
- package/dist/backends/android-instrumentation/artifact.mjs.map +1 -0
- package/dist/backends/android-instrumentation/errors.cjs +79 -0
- package/dist/backends/android-instrumentation/errors.cjs.map +1 -0
- package/dist/backends/android-instrumentation/errors.d.cts +68 -0
- package/dist/backends/android-instrumentation/errors.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/errors.d.mts +68 -0
- package/dist/backends/android-instrumentation/errors.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/errors.mjs +73 -0
- package/dist/backends/android-instrumentation/errors.mjs.map +1 -0
- package/dist/backends/android-instrumentation/installer.cjs +235 -0
- package/dist/backends/android-instrumentation/installer.cjs.map +1 -0
- package/dist/backends/android-instrumentation/installer.d.cts +123 -0
- package/dist/backends/android-instrumentation/installer.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/installer.d.mts +123 -0
- package/dist/backends/android-instrumentation/installer.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/installer.mjs +225 -0
- package/dist/backends/android-instrumentation/installer.mjs.map +1 -0
- package/dist/backends/android-instrumentation/signer.cjs +435 -0
- package/dist/backends/android-instrumentation/signer.cjs.map +1 -0
- package/dist/backends/android-instrumentation/signer.d.cts +40 -0
- package/dist/backends/android-instrumentation/signer.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/signer.d.mts +40 -0
- package/dist/backends/android-instrumentation/signer.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/signer.mjs +431 -0
- package/dist/backends/android-instrumentation/signer.mjs.map +1 -0
- package/dist/backends/android-instrumentation/snapshot.cjs +159 -0
- package/dist/backends/android-instrumentation/snapshot.cjs.map +1 -0
- package/dist/backends/android-instrumentation/snapshot.d.cts +67 -0
- package/dist/backends/android-instrumentation/snapshot.d.cts.map +1 -0
- package/dist/backends/android-instrumentation/snapshot.d.mts +67 -0
- package/dist/backends/android-instrumentation/snapshot.d.mts.map +1 -0
- package/dist/backends/android-instrumentation/snapshot.mjs +154 -0
- package/dist/backends/android-instrumentation/snapshot.mjs.map +1 -0
- package/dist/backends/appium-backend.cjs +19 -1
- package/dist/backends/appium-backend.cjs.map +1 -1
- package/dist/backends/appium-backend.d.cts +1 -0
- package/dist/backends/appium-backend.d.cts.map +1 -1
- package/dist/backends/appium-backend.d.mts +1 -0
- package/dist/backends/appium-backend.d.mts.map +1 -1
- package/dist/backends/appium-backend.mjs +19 -1
- package/dist/backends/appium-backend.mjs.map +1 -1
- package/dist/backends/idb-backend.cjs +128 -31
- package/dist/backends/idb-backend.cjs.map +1 -1
- package/dist/backends/idb-backend.d.cts +1 -0
- package/dist/backends/idb-backend.d.cts.map +1 -1
- package/dist/backends/idb-backend.d.mts +1 -0
- package/dist/backends/idb-backend.d.mts.map +1 -1
- package/dist/backends/idb-backend.mjs +129 -32
- package/dist/backends/idb-backend.mjs.map +1 -1
- package/dist/backends/index.cjs +3 -0
- package/dist/backends/index.cjs.map +1 -1
- package/dist/backends/index.d.cts.map +1 -1
- package/dist/backends/index.d.mts.map +1 -1
- package/dist/backends/index.mjs +3 -0
- package/dist/backends/index.mjs.map +1 -1
- package/dist/backends/types.cjs.map +1 -1
- package/dist/backends/types.d.cts +6 -0
- package/dist/backends/types.d.cts.map +1 -1
- package/dist/backends/types.d.mts +6 -0
- package/dist/backends/types.d.mts.map +1 -1
- package/dist/backends/types.mjs.map +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/tools/swipe.cjs +3 -1
- package/dist/tools/swipe.cjs.map +1 -1
- package/dist/tools/swipe.d.cts.map +1 -1
- package/dist/tools/swipe.d.mts.map +1 -1
- package/dist/tools/swipe.mjs +3 -1
- package/dist/tools/swipe.mjs.map +1 -1
- package/dist/utils/output-path.cjs +13 -0
- package/dist/utils/output-path.cjs.map +1 -1
- package/dist/utils/output-path.d.cts +10 -0
- package/dist/utils/output-path.d.cts.map +1 -1
- package/dist/utils/output-path.d.mts +10 -0
- package/dist/utils/output-path.d.mts.map +1 -1
- package/dist/utils/output-path.mjs +12 -0
- package/dist/utils/output-path.mjs.map +1 -1
- package/dist/utils/platform.cjs +65 -7
- package/dist/utils/platform.cjs.map +1 -1
- package/dist/utils/platform.d.cts +33 -0
- package/dist/utils/platform.d.cts.map +1 -1
- package/dist/utils/platform.d.mts +33 -0
- package/dist/utils/platform.d.mts.map +1 -1
- package/dist/utils/platform.mjs +63 -7
- package/dist/utils/platform.mjs.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// Implemented from the public AOSP APK Signature Scheme v2/v3 spec.
|
|
2
|
+
/**
|
|
3
|
+
* Pure-JS verifier for APK Signature Scheme v2 and v3.
|
|
4
|
+
*
|
|
5
|
+
* This proves an APK was cryptographically SIGNED by a given key — it verifies
|
|
6
|
+
* the signature over the recomputed APK content digest, then binds the leaf
|
|
7
|
+
* certificate to that key. It is NOT a cert-presence check: reading a
|
|
8
|
+
* certificate out of an APK proves nothing (certificates are public), so this
|
|
9
|
+
* module recomputes the content digest and verifies the signature before
|
|
10
|
+
* trusting the signer. Used to gate `am instrument` against a helper that could
|
|
11
|
+
* otherwise be a package-name squatter.
|
|
12
|
+
*
|
|
13
|
+
* No external dependencies, no Android SDK, no shelling out — `node:crypto` and
|
|
14
|
+
* `node:fs` only, so it runs on any consumer machine that runs the MCP server.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, createPublicKey, verify as cryptoVerify, X509Certificate } from "node:crypto";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import { SnapshotHelperError, UntrustedHelperError } from "./errors.mjs";
|
|
19
|
+
const MAX_APK_BYTES = 200 * 1024 * 1024;
|
|
20
|
+
const EOCD_SIGNATURE = 101010256;
|
|
21
|
+
const EOCD_MIN_SIZE = 22;
|
|
22
|
+
const EOCD_CD_OFFSET_FIELD = 16;
|
|
23
|
+
const EOCD_COMMENT_LENGTH_FIELD = 20;
|
|
24
|
+
const SIG_BLOCK_MAGIC = Buffer.from('APK Sig Block 42', 'latin1');
|
|
25
|
+
const SIG_BLOCK_FOOTER_SIZE = 24;
|
|
26
|
+
const V2_BLOCK_ID = 1896449818;
|
|
27
|
+
const V3_BLOCK_ID = 4031998144;
|
|
28
|
+
const CHUNK_SIZE = 1024 * 1024;
|
|
29
|
+
const CHUNK_PREFIX = 0xa5;
|
|
30
|
+
const TOP_LEVEL_PREFIX = 0x5a;
|
|
31
|
+
const RSA_PKCS1_PSS_PADDING = 6;
|
|
32
|
+
/**
|
|
33
|
+
* Map an APK signature-algorithm ID to its hash and verification strategy.
|
|
34
|
+
* IDs and their crypto meanings are fixed by the AOSP spec.
|
|
35
|
+
*
|
|
36
|
+
* @param id - The 32-bit signature algorithm identifier.
|
|
37
|
+
* @returns The algorithm descriptor, or null when unsupported (e.g. fs-verity).
|
|
38
|
+
*/
|
|
39
|
+
function sigAlgo(id) {
|
|
40
|
+
switch (id) {
|
|
41
|
+
case 0x0101:
|
|
42
|
+
return {
|
|
43
|
+
hash: 'sha256',
|
|
44
|
+
strength: 1,
|
|
45
|
+
verify: (data, key, signature) => cryptoVerify('sha256', data, { key, padding: RSA_PKCS1_PSS_PADDING, saltLength: 32 }, signature),
|
|
46
|
+
};
|
|
47
|
+
case 0x0102:
|
|
48
|
+
return {
|
|
49
|
+
hash: 'sha512',
|
|
50
|
+
strength: 3,
|
|
51
|
+
verify: (data, key, signature) => cryptoVerify('sha512', data, { key, padding: RSA_PKCS1_PSS_PADDING, saltLength: 64 }, signature),
|
|
52
|
+
};
|
|
53
|
+
case 0x0103:
|
|
54
|
+
return {
|
|
55
|
+
hash: 'sha256',
|
|
56
|
+
strength: 0,
|
|
57
|
+
verify: (data, key, signature) => cryptoVerify('sha256', data, key, signature),
|
|
58
|
+
};
|
|
59
|
+
case 0x0104:
|
|
60
|
+
return {
|
|
61
|
+
hash: 'sha512',
|
|
62
|
+
strength: 2,
|
|
63
|
+
verify: (data, key, signature) => cryptoVerify('sha512', data, key, signature),
|
|
64
|
+
};
|
|
65
|
+
case 0x0201:
|
|
66
|
+
return {
|
|
67
|
+
hash: 'sha256',
|
|
68
|
+
strength: 1,
|
|
69
|
+
verify: (data, key, signature) => cryptoVerify('sha256', data, key, signature),
|
|
70
|
+
};
|
|
71
|
+
case 0x0202:
|
|
72
|
+
return {
|
|
73
|
+
hash: 'sha512',
|
|
74
|
+
strength: 3,
|
|
75
|
+
verify: (data, key, signature) => cryptoVerify('sha512', data, key, signature),
|
|
76
|
+
};
|
|
77
|
+
case 0x0301:
|
|
78
|
+
return {
|
|
79
|
+
hash: 'sha256',
|
|
80
|
+
strength: 1,
|
|
81
|
+
verify: (data, key, signature) => cryptoVerify('sha256', data, key, signature),
|
|
82
|
+
};
|
|
83
|
+
default:
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Raise an {@link SnapshotHelperError} for any malformed or unverifiable APK.
|
|
89
|
+
*
|
|
90
|
+
* @param message - What went wrong.
|
|
91
|
+
* @throws Always.
|
|
92
|
+
*/
|
|
93
|
+
function invalid(message) {
|
|
94
|
+
throw new SnapshotHelperError('ARTIFACT_INVALID', message);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Find the ZIP End Of Central Directory record by scanning backwards.
|
|
98
|
+
*
|
|
99
|
+
* @param apk - The whole APK file.
|
|
100
|
+
* @returns The EOCD start offset plus the central-directory offset and size it
|
|
101
|
+
* declares.
|
|
102
|
+
*/
|
|
103
|
+
function findEocd(apk) {
|
|
104
|
+
const minStart = Math.max(0, apk.length - EOCD_MIN_SIZE - 0xffff);
|
|
105
|
+
for (let i = apk.length - EOCD_MIN_SIZE; i >= minStart; i -= 1) {
|
|
106
|
+
if (apk.readUInt32LE(i) !== EOCD_SIGNATURE) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const commentLength = apk.readUInt16LE(i + EOCD_COMMENT_LENGTH_FIELD);
|
|
110
|
+
if (i + EOCD_MIN_SIZE + commentLength !== apk.length) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
eocdOffset: i,
|
|
115
|
+
centralDirOffset: apk.readUInt32LE(i + EOCD_CD_OFFSET_FIELD),
|
|
116
|
+
centralDirSize: apk.readUInt32LE(i + 12),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return invalid('APK is missing a ZIP End Of Central Directory record');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Locate the APK Signing Block that sits immediately before the central
|
|
123
|
+
* directory.
|
|
124
|
+
*
|
|
125
|
+
* @param apk - The whole APK file.
|
|
126
|
+
* @param centralDirOffset - The central-directory offset from the EOCD.
|
|
127
|
+
* @param eocdOffset - The EOCD start offset.
|
|
128
|
+
* @param centralDirSize - The central-directory size from the EOCD.
|
|
129
|
+
* @returns The signing-block offset and its payload (between the two size
|
|
130
|
+
* fields, excluding the trailing size and magic).
|
|
131
|
+
*/
|
|
132
|
+
function findSigningBlock(apk, centralDirOffset, eocdOffset, centralDirSize) {
|
|
133
|
+
if (centralDirOffset + centralDirSize !== eocdOffset) {
|
|
134
|
+
invalid('Central directory is not immediately followed by the EOCD');
|
|
135
|
+
}
|
|
136
|
+
if (centralDirOffset < SIG_BLOCK_FOOTER_SIZE) {
|
|
137
|
+
invalid('No APK Signing Block before the central directory');
|
|
138
|
+
}
|
|
139
|
+
const footer = apk.subarray(centralDirOffset - SIG_BLOCK_FOOTER_SIZE, centralDirOffset);
|
|
140
|
+
if (!footer.subarray(8, 24).equals(SIG_BLOCK_MAGIC)) {
|
|
141
|
+
invalid('APK Signing Block magic not found (APK is not v2/v3 signed)');
|
|
142
|
+
}
|
|
143
|
+
const trailerSize = footer.readBigUInt64LE(0);
|
|
144
|
+
const totalSize = Number(trailerSize) + 8;
|
|
145
|
+
const apkSigBlockOffset = centralDirOffset - totalSize;
|
|
146
|
+
if (apkSigBlockOffset < 0) {
|
|
147
|
+
invalid('APK Signing Block size is out of range');
|
|
148
|
+
}
|
|
149
|
+
const headerSize = apk.readBigUInt64LE(apkSigBlockOffset);
|
|
150
|
+
if (headerSize !== trailerSize) {
|
|
151
|
+
invalid('APK Signing Block header/footer size mismatch');
|
|
152
|
+
}
|
|
153
|
+
const pairs = apk.subarray(apkSigBlockOffset + 8, centralDirOffset - SIG_BLOCK_FOOTER_SIZE);
|
|
154
|
+
return { apkSigBlockOffset, pairs };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Extract a scheme block (v2 or v3) from the signing block's ID/value pairs.
|
|
158
|
+
*
|
|
159
|
+
* @param pairs - The concatenated ID/value pairs.
|
|
160
|
+
* @returns The v3 block if present, else the v2 block, with its scheme label.
|
|
161
|
+
*/
|
|
162
|
+
function selectSchemeBlock(pairs) {
|
|
163
|
+
const blocks = new Map();
|
|
164
|
+
let cursor = 0;
|
|
165
|
+
while (cursor + 8 <= pairs.length) {
|
|
166
|
+
const pairSize = Number(pairs.readBigUInt64LE(cursor));
|
|
167
|
+
cursor += 8;
|
|
168
|
+
if (pairSize < 4 || cursor + pairSize > pairs.length) {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
const id = pairs.readUInt32LE(cursor);
|
|
172
|
+
const value = pairs.subarray(cursor + 4, cursor + pairSize);
|
|
173
|
+
blocks.set(id, value);
|
|
174
|
+
cursor += pairSize;
|
|
175
|
+
}
|
|
176
|
+
const v3 = blocks.get(V3_BLOCK_ID);
|
|
177
|
+
if (v3) {
|
|
178
|
+
return { scheme: 'v3', block: v3 };
|
|
179
|
+
}
|
|
180
|
+
const v2 = blocks.get(V2_BLOCK_ID);
|
|
181
|
+
if (v2) {
|
|
182
|
+
return { scheme: 'v2', block: v2 };
|
|
183
|
+
}
|
|
184
|
+
return invalid('No v2 or v3 signature block in the APK Signing Block');
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Read a `uint32`-length-prefixed slice, advancing the cursor past it.
|
|
188
|
+
*
|
|
189
|
+
* @param bytes - The buffer to read from.
|
|
190
|
+
* @param cursor - The current read offset.
|
|
191
|
+
* @returns The inner bytes and the offset just past them.
|
|
192
|
+
*/
|
|
193
|
+
function readLenPrefixed(bytes, cursor) {
|
|
194
|
+
if (cursor + 4 > bytes.length) {
|
|
195
|
+
invalid('Truncated length-prefixed field');
|
|
196
|
+
}
|
|
197
|
+
const len = bytes.readUInt32LE(cursor);
|
|
198
|
+
const start = cursor + 4;
|
|
199
|
+
const end = start + len;
|
|
200
|
+
if (end > bytes.length) {
|
|
201
|
+
invalid('Length-prefixed field runs past end of block');
|
|
202
|
+
}
|
|
203
|
+
return { value: bytes.subarray(start, end), next: end };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Split a length-prefixed sequence into its length-prefixed elements.
|
|
207
|
+
*
|
|
208
|
+
* @param seq - The sequence bytes (already unwrapped from its own prefix).
|
|
209
|
+
* @returns The element buffers in order.
|
|
210
|
+
*/
|
|
211
|
+
function readSequence(seq) {
|
|
212
|
+
const items = [];
|
|
213
|
+
let cursor = 0;
|
|
214
|
+
while (cursor < seq.length) {
|
|
215
|
+
const { value, next } = readLenPrefixed(seq, cursor);
|
|
216
|
+
items.push(value);
|
|
217
|
+
cursor = next;
|
|
218
|
+
}
|
|
219
|
+
return items;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Parse the `{ uint32 algoId; len-prefixed payload }` records used by both the
|
|
223
|
+
* signed-data digests list and the signatures list.
|
|
224
|
+
*
|
|
225
|
+
* @param seq - The sequence bytes.
|
|
226
|
+
* @returns The parsed algorithm-id/payload records in order.
|
|
227
|
+
*/
|
|
228
|
+
function readAlgoRecords(seq) {
|
|
229
|
+
return readSequence(seq).map((element) => {
|
|
230
|
+
if (element.length < 4) {
|
|
231
|
+
invalid('Algorithm record too short');
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
algoId: element.readUInt32LE(0),
|
|
235
|
+
payload: readLenPrefixed(element, 4).value,
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Parse a single signer out of a v2/v3 scheme block.
|
|
241
|
+
*
|
|
242
|
+
* @param block - The scheme block value (a length-prefixed signers sequence).
|
|
243
|
+
* @param scheme - Which scheme is being parsed (v3 carries extra SDK fields).
|
|
244
|
+
* @returns The parsed signer.
|
|
245
|
+
*/
|
|
246
|
+
function parseSigner(block, scheme) {
|
|
247
|
+
const signers = readSequence(readLenPrefixed(block, 0).value);
|
|
248
|
+
if (signers.length !== 1) {
|
|
249
|
+
invalid(`Expected exactly one signer, found ${signers.length}`);
|
|
250
|
+
}
|
|
251
|
+
const signer = signers[0];
|
|
252
|
+
let cursor = 0;
|
|
253
|
+
const signedDataRead = readLenPrefixed(signer, cursor);
|
|
254
|
+
const signedData = signedDataRead.value;
|
|
255
|
+
cursor = signedDataRead.next;
|
|
256
|
+
let signerMinSdk;
|
|
257
|
+
let signerMaxSdk;
|
|
258
|
+
if (scheme === 'v3') {
|
|
259
|
+
if (cursor + 8 > signer.length) {
|
|
260
|
+
invalid('v3 signer missing min/max SDK');
|
|
261
|
+
}
|
|
262
|
+
signerMinSdk = signer.readUInt32LE(cursor);
|
|
263
|
+
signerMaxSdk = signer.readUInt32LE(cursor + 4);
|
|
264
|
+
cursor += 8;
|
|
265
|
+
}
|
|
266
|
+
const signaturesRead = readLenPrefixed(signer, cursor);
|
|
267
|
+
cursor = signaturesRead.next;
|
|
268
|
+
const publicKeyRead = readLenPrefixed(signer, cursor);
|
|
269
|
+
let sdCursor = 0;
|
|
270
|
+
const digestsRead = readLenPrefixed(signedData, sdCursor);
|
|
271
|
+
sdCursor = digestsRead.next;
|
|
272
|
+
const certificatesRead = readLenPrefixed(signedData, sdCursor);
|
|
273
|
+
sdCursor = certificatesRead.next;
|
|
274
|
+
let signedMinSdk;
|
|
275
|
+
let signedMaxSdk;
|
|
276
|
+
if (scheme === 'v3') {
|
|
277
|
+
if (sdCursor + 8 > signedData.length) {
|
|
278
|
+
invalid('v3 signed-data missing min/max SDK');
|
|
279
|
+
}
|
|
280
|
+
signedMinSdk = signedData.readUInt32LE(sdCursor);
|
|
281
|
+
signedMaxSdk = signedData.readUInt32LE(sdCursor + 4);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
signedData,
|
|
285
|
+
digests: readAlgoRecords(digestsRead.value),
|
|
286
|
+
certificates: readSequence(certificatesRead.value),
|
|
287
|
+
signatures: readAlgoRecords(signaturesRead.value),
|
|
288
|
+
publicKey: publicKeyRead.value,
|
|
289
|
+
signedMinSdk,
|
|
290
|
+
signedMaxSdk,
|
|
291
|
+
signerMinSdk,
|
|
292
|
+
signerMaxSdk,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Compute the chunked content digest of one segment, appending each chunk's
|
|
297
|
+
* digest to `out`.
|
|
298
|
+
*
|
|
299
|
+
* @param segment - The segment bytes.
|
|
300
|
+
* @param hash - The digest algorithm.
|
|
301
|
+
* @param out - Accumulator for per-chunk digests.
|
|
302
|
+
* @returns The number of chunks produced.
|
|
303
|
+
*/
|
|
304
|
+
function digestSegment(segment, hash, out) {
|
|
305
|
+
let chunks = 0;
|
|
306
|
+
for (let offset = 0; offset < segment.length; offset += CHUNK_SIZE) {
|
|
307
|
+
const chunk = segment.subarray(offset, offset + CHUNK_SIZE);
|
|
308
|
+
const prefix = Buffer.allocUnsafe(5);
|
|
309
|
+
prefix.writeUInt8(CHUNK_PREFIX, 0);
|
|
310
|
+
prefix.writeUInt32LE(chunk.length, 1);
|
|
311
|
+
out.push(createHash(hash).update(prefix).update(chunk).digest());
|
|
312
|
+
chunks += 1;
|
|
313
|
+
}
|
|
314
|
+
return chunks;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Recompute the APK content digest per the v2/v3 spec: chunk the pre-signing
|
|
318
|
+
* bytes, the central directory, and the EOCD (patched so its central-directory
|
|
319
|
+
* offset points at the signing block), then hash the concatenated chunk
|
|
320
|
+
* digests.
|
|
321
|
+
*
|
|
322
|
+
* @param apk - The whole APK file.
|
|
323
|
+
* @param apkSigBlockOffset - Offset of the APK Signing Block.
|
|
324
|
+
* @param centralDirOffset - Central-directory offset.
|
|
325
|
+
* @param eocdOffset - EOCD start offset.
|
|
326
|
+
* @param hash - The content-digest algorithm.
|
|
327
|
+
* @returns The recomputed content digest.
|
|
328
|
+
*/
|
|
329
|
+
function computeContentDigest(apk, apkSigBlockOffset, centralDirOffset, eocdOffset, hash) {
|
|
330
|
+
const beforeBlock = apk.subarray(0, apkSigBlockOffset);
|
|
331
|
+
const centralDir = apk.subarray(centralDirOffset, eocdOffset);
|
|
332
|
+
const eocd = Buffer.from(apk.subarray(eocdOffset));
|
|
333
|
+
eocd.writeUInt32LE(apkSigBlockOffset, EOCD_CD_OFFSET_FIELD);
|
|
334
|
+
const chunkDigests = [];
|
|
335
|
+
let count = 0;
|
|
336
|
+
count += digestSegment(beforeBlock, hash, chunkDigests);
|
|
337
|
+
count += digestSegment(centralDir, hash, chunkDigests);
|
|
338
|
+
count += digestSegment(eocd, hash, chunkDigests);
|
|
339
|
+
const top = createHash(hash);
|
|
340
|
+
const header = Buffer.allocUnsafe(5);
|
|
341
|
+
header.writeUInt8(TOP_LEVEL_PREFIX, 0);
|
|
342
|
+
header.writeUInt32LE(count, 1);
|
|
343
|
+
top.update(header);
|
|
344
|
+
for (const digest of chunkDigests) {
|
|
345
|
+
top.update(digest);
|
|
346
|
+
}
|
|
347
|
+
return top.digest();
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Verify an APK's v2/v3 signature and return its leaf signer certificate
|
|
351
|
+
* SHA-256.
|
|
352
|
+
*
|
|
353
|
+
* @param apkPath - Path to the APK on disk.
|
|
354
|
+
* @returns The verified scheme and lowercase-hex leaf certificate SHA-256.
|
|
355
|
+
* @throws {@link SnapshotHelperError} when the APK is malformed or the
|
|
356
|
+
* signature does not verify.
|
|
357
|
+
*/
|
|
358
|
+
export async function verifyApkSignerSha256(apkPath) {
|
|
359
|
+
const apk = await readFile(apkPath);
|
|
360
|
+
if (apk.length > MAX_APK_BYTES) {
|
|
361
|
+
invalid(`APK exceeds ${MAX_APK_BYTES} bytes`);
|
|
362
|
+
}
|
|
363
|
+
const { eocdOffset, centralDirOffset, centralDirSize } = findEocd(apk);
|
|
364
|
+
const { apkSigBlockOffset, pairs } = findSigningBlock(apk, centralDirOffset, eocdOffset, centralDirSize);
|
|
365
|
+
const { scheme, block } = selectSchemeBlock(pairs);
|
|
366
|
+
const signer = parseSigner(block, scheme);
|
|
367
|
+
if (signer.certificates.length < 1) {
|
|
368
|
+
invalid('Signer has no certificates');
|
|
369
|
+
}
|
|
370
|
+
const selected = signer.signatures
|
|
371
|
+
.map((signature) => ({ signature, algo: sigAlgo(signature.algoId) }))
|
|
372
|
+
.filter((entry) => entry.algo !== null)
|
|
373
|
+
.sort((a, b) => b.algo.strength - a.algo.strength)
|
|
374
|
+
.at(0);
|
|
375
|
+
if (!selected) {
|
|
376
|
+
invalid('No supported signature algorithm in signer');
|
|
377
|
+
}
|
|
378
|
+
const publicKey = createPublicKey({
|
|
379
|
+
key: signer.publicKey,
|
|
380
|
+
format: 'der',
|
|
381
|
+
type: 'spki',
|
|
382
|
+
});
|
|
383
|
+
if (!selected.algo.verify(signer.signedData, publicKey, selected.signature.payload)) {
|
|
384
|
+
invalid('Signature did not verify over signed-data');
|
|
385
|
+
}
|
|
386
|
+
const leaf = signer.certificates[0];
|
|
387
|
+
const certPublicKey = new X509Certificate(leaf).publicKey;
|
|
388
|
+
const certSpki = certPublicKey.export({ format: 'der', type: 'spki' });
|
|
389
|
+
if (!certSpki.equals(signer.publicKey)) {
|
|
390
|
+
invalid('Leaf certificate public key does not match the block public key');
|
|
391
|
+
}
|
|
392
|
+
const sigIds = signer.signatures.map((s) => s.algoId);
|
|
393
|
+
const digestIds = signer.digests.map((d) => d.algoId);
|
|
394
|
+
if (sigIds.length !== digestIds.length ||
|
|
395
|
+
sigIds.some((id, index) => id !== digestIds[index])) {
|
|
396
|
+
invalid('Signature and digest algorithm lists differ');
|
|
397
|
+
}
|
|
398
|
+
const expectedDigest = signer.digests.find((d) => d.algoId === selected.signature.algoId);
|
|
399
|
+
if (!expectedDigest) {
|
|
400
|
+
invalid('No content digest for the selected algorithm');
|
|
401
|
+
}
|
|
402
|
+
const computed = computeContentDigest(apk, apkSigBlockOffset, centralDirOffset, eocdOffset, selected.algo.hash);
|
|
403
|
+
if (!computed.equals(expectedDigest.payload)) {
|
|
404
|
+
invalid('Recomputed APK content digest does not match signed digest');
|
|
405
|
+
}
|
|
406
|
+
if (scheme === 'v3') {
|
|
407
|
+
if (signer.signerMinSdk !== signer.signedMinSdk ||
|
|
408
|
+
signer.signerMaxSdk !== signer.signedMaxSdk) {
|
|
409
|
+
invalid('v3 signer min/max SDK does not match signed-data');
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const signerSha256 = createHash('sha256').update(leaf).digest('hex');
|
|
413
|
+
return { verified: true, signerSha256, scheme };
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Verify an APK and assert its signer matches an expected pinned SHA-256.
|
|
417
|
+
*
|
|
418
|
+
* @param apkPath - Path to the APK on disk.
|
|
419
|
+
* @param expectedSignerSha256 - The pinned lowercase-hex signer SHA-256.
|
|
420
|
+
* @throws {@link UntrustedHelperError} when the (validly signed) APK's signer
|
|
421
|
+
* does not match the pin.
|
|
422
|
+
* @throws {@link SnapshotHelperError} when the APK is malformed or unverifiable.
|
|
423
|
+
*/
|
|
424
|
+
export async function assertApkSignerSha256(apkPath, expectedSignerSha256) {
|
|
425
|
+
const { signerSha256 } = await verifyApkSignerSha256(apkPath);
|
|
426
|
+
const expected = expectedSignerSha256.toLowerCase();
|
|
427
|
+
if (signerSha256 !== expected) {
|
|
428
|
+
throw new UntrustedHelperError('Installed snapshot helper is signed by an unexpected certificate', { expectedSignerSha256: expected, actualSignerSha256: signerSha256 });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
//# sourceMappingURL=signer.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signer.mjs","sourceRoot":"","sources":["../../../src/backends/android-instrumentation/signer.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,UAAU,EACV,eAAe,EACf,MAAM,IAAI,YAAY,EACtB,eAAe,EAChB,oBAAoB;AACrB,OAAO,EAAE,QAAQ,EAAE,yBAAyB;AAE5C,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,qBAAoB;AAQxE,MAAM,aAAa,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC;AAExC,MAAM,cAAc,GAAG,SAAW,CAAC;AACnC,MAAM,aAAa,GAAG,EAAE,CAAC;AACzB,MAAM,oBAAoB,GAAG,EAAE,CAAC;AAChC,MAAM,yBAAyB,GAAG,EAAE,CAAC;AAErC,MAAM,eAAe,GAAG,MAAM,CAAC,IAAI,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;AAClE,MAAM,qBAAqB,GAAG,EAAE,CAAC;AAEjC,MAAM,WAAW,GAAG,UAAW,CAAC;AAChC,MAAM,WAAW,GAAG,UAAW,CAAC;AAEhC,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,CAAC;AAC/B,MAAM,YAAY,GAAG,IAAI,CAAC;AAC1B,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAc9B,MAAM,qBAAqB,GAAG,CAAC,CAAC;AAEhC;;;;;;GAMG;AACH,SAAS,OAAO,CAAC,EAAU;IACzB,QAAQ,EAAE,EAAE,CAAC;QACX,KAAK,MAAM;YACT,OAAO;gBACL,IAAI,EAAE,QAAQ;gBACd,QAAQ,EAAE,CAAC;gBACX,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,CAC/B,YAAY,CACV,QAAQ,EACR,IAAI,EACJ,EAAE,GAAG,EAAE,OAAO,EAAE,qBAAqB,EAAE,UAAU,EAAE,EAAE,EAAE,EACvD,SAAS,CACV;aACJ,CAAC;QACJ,KAAK,MAAM;YACT,OAAO;gBACL,IAAI,EAAE,QAAQ;gBACd,QAAQ,EAAE,CAAC;gBACX,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,CAC/B,YAAY,CACV,QAAQ,EACR,IAAI,EACJ,EAAE,GAAG,EAAE,OAAO,EAAE,qBAAqB,EAAE,UAAU,EAAE,EAAE,EAAE,EACvD,SAAS,CACV;aACJ,CAAC;QACJ,KAAK,MAAM;YACT,OAAO;gBACL,IAAI,EAAE,QAAQ;gBACd,QAAQ,EAAE,CAAC;gBACX,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,CAC/B,YAAY,CAAC,QAAQ,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,CAAC;aAC/C,CAAC;QACJ,KAAK,MAAM;YACT,OAAO;gBACL,IAAI,EAAE,QAAQ;gBACd,QAAQ,EAAE,CAAC;gBACX,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,CAC/B,YAAY,CAAC,QAAQ,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,CAAC;aAC/C,CAAC;QACJ,KAAK,MAAM;YACT,OAAO;gBACL,IAAI,EAAE,QAAQ;gBACd,QAAQ,EAAE,CAAC;gBACX,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,CAC/B,YAAY,CAAC,QAAQ,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,CAAC;aAC/C,CAAC;QACJ,KAAK,MAAM;YACT,OAAO;gBACL,IAAI,EAAE,QAAQ;gBACd,QAAQ,EAAE,CAAC;gBACX,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,CAC/B,YAAY,CAAC,QAAQ,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,CAAC;aAC/C,CAAC;QACJ,KAAK,MAAM;YACT,OAAO;gBACL,IAAI,EAAE,QAAQ;gBACd,QAAQ,EAAE,CAAC;gBACX,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,CAC/B,YAAY,CAAC,QAAQ,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,CAAC;aAC/C,CAAC;QACJ;YACE,OAAO,IAAI,CAAC;IAChB,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,SAAS,OAAO,CAAC,OAAe;IAC9B,MAAM,IAAI,mBAAmB,CAAC,kBAAkB,EAAE,OAAO,CAAC,CAAC;AAC7D,CAAC;AAED;;;;;;GAMG;AACH,SAAS,QAAQ,CAAC,GAAW;IAK3B,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,CAAC,MAAM,GAAG,aAAa,GAAG,MAAM,CAAC,CAAC;IAClE,KAAK,IAAI,CAAC,GAAG,GAAG,CAAC,MAAM,GAAG,aAAa,EAAE,CAAC,IAAI,QAAQ,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/D,IAAI,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,KAAK,cAAc,EAAE,CAAC;YAC3C,SAAS;QACX,CAAC;QACD,MAAM,aAAa,GAAG,GAAG,CAAC,YAAY,CAAC,CAAC,GAAG,yBAAyB,CAAC,CAAC;QACtE,IAAI,CAAC,GAAG,aAAa,GAAG,aAAa,KAAK,GAAG,CAAC,MAAM,EAAE,CAAC;YACrD,SAAS;QACX,CAAC;QACD,OAAO;YACL,UAAU,EAAE,CAAC;YACb,gBAAgB,EAAE,GAAG,CAAC,YAAY,CAAC,CAAC,GAAG,oBAAoB,CAAC;YAC5D,cAAc,EAAE,GAAG,CAAC,YAAY,CAAC,CAAC,GAAG,EAAE,CAAC;SACzC,CAAC;IACJ,CAAC;IACD,OAAO,OAAO,CAAC,sDAAsD,CAAC,CAAC;AACzE,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAS,gBAAgB,CACvB,GAAW,EACX,gBAAwB,EACxB,UAAkB,EAClB,cAAsB;IAEtB,IAAI,gBAAgB,GAAG,cAAc,KAAK,UAAU,EAAE,CAAC;QACrD,OAAO,CAAC,2DAA2D,CAAC,CAAC;IACvE,CAAC;IACD,IAAI,gBAAgB,GAAG,qBAAqB,EAAE,CAAC;QAC7C,OAAO,CAAC,mDAAmD,CAAC,CAAC;IAC/D,CAAC;IACD,MAAM,MAAM,GAAG,GAAG,CAAC,QAAQ,CACzB,gBAAgB,GAAG,qBAAqB,EACxC,gBAAgB,CACjB,CAAC;IACF,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,eAAe,CAAC,EAAE,CAAC;QACpD,OAAO,CAAC,6DAA6D,CAAC,CAAC;IACzE,CAAC;IACD,MAAM,WAAW,GAAG,MAAM,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;IAC9C,MAAM,SAAS,GAAG,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IAC1C,MAAM,iBAAiB,GAAG,gBAAgB,GAAG,SAAS,CAAC;IACvD,IAAI,iBAAiB,GAAG,CAAC,EAAE,CAAC;QAC1B,OAAO,CAAC,wCAAwC,CAAC,CAAC;IACpD,CAAC;IACD,MAAM,UAAU,GAAG,GAAG,CAAC,eAAe,CAAC,iBAAiB,CAAC,CAAC;IAC1D,IAAI,UAAU,KAAK,WAAW,EAAE,CAAC;QAC/B,OAAO,CAAC,+CAA+C,CAAC,CAAC;IAC3D,CAAC;IACD,MAAM,KAAK,GAAG,GAAG,CAAC,QAAQ,CACxB,iBAAiB,GAAG,CAAC,EACrB,gBAAgB,GAAG,qBAAqB,CACzC,CAAC;IACF,OAAO,EAAE,iBAAiB,EAAE,KAAK,EAAE,CAAC;AACtC,CAAC;AAED;;;;;GAKG;AACH,SAAS,iBAAiB,CAAC,KAAa;IAItC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,OAAO,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;QAClC,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC;QACvD,MAAM,IAAI,CAAC,CAAC;QACZ,IAAI,QAAQ,GAAG,CAAC,IAAI,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC;YACrD,MAAM;QACR,CAAC;QACD,MAAM,EAAE,GAAG,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;QACtC,MAAM,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC,CAAC;QAC5D,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;QACtB,MAAM,IAAI,QAAQ,CAAC;IACrB,CAAC;IACD,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IACnC,IAAI,EAAE,EAAE,CAAC;QACP,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;IACrC,CAAC;IACD,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IACnC,IAAI,EAAE,EAAE,CAAC;QACP,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;IACrC,CAAC;IACD,OAAO,OAAO,CAAC,sDAAsD,CAAC,CAAC;AACzE,CAAC;AAED;;;;;;GAMG;AACH,SAAS,eAAe,CACtB,KAAa,EACb,MAAc;IAEd,IAAI,MAAM,GAAG,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC;QAC9B,OAAO,CAAC,iCAAiC,CAAC,CAAC;IAC7C,CAAC;IACD,MAAM,GAAG,GAAG,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IACvC,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,CAAC;IACzB,MAAM,GAAG,GAAG,KAAK,GAAG,GAAG,CAAC;IACxB,IAAI,GAAG,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC;QACvB,OAAO,CAAC,8CAA8C,CAAC,CAAC;IAC1D,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;AAC1D,CAAC;AAED;;;;;GAKG;AACH,SAAS,YAAY,CAAC,GAAW;IAC/B,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,OAAO,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;QAC3B,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,eAAe,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QACrD,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAClB,MAAM,GAAG,IAAI,CAAC;IAChB,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAID;;;;;;GAMG;AACH,SAAS,eAAe,CAAC,GAAW;IAClC,OAAO,YAAY,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;QACvC,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACvB,OAAO,CAAC,4BAA4B,CAAC,CAAC;QACxC,CAAC;QACD,OAAO;YACL,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC;YAC/B,OAAO,EAAE,eAAe,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,KAAK;SAC3C,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC;AAcD;;;;;;GAMG;AACH,SAAS,WAAW,CAAC,KAAa,EAAE,MAAmB;IACrD,MAAM,OAAO,GAAG,YAAY,CAAC,eAAe,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAC9D,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,CAAC,sCAAsC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAClE,CAAC;IACD,MAAM,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAE1B,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,MAAM,cAAc,GAAG,eAAe,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvD,MAAM,UAAU,GAAG,cAAc,CAAC,KAAK,CAAC;IACxC,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC;IAE7B,IAAI,YAAgC,CAAC;IACrC,IAAI,YAAgC,CAAC;IACrC,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;QACpB,IAAI,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;YAC/B,OAAO,CAAC,+BAA+B,CAAC,CAAC;QAC3C,CAAC;QACD,YAAY,GAAG,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;QAC3C,YAAY,GAAG,MAAM,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QAC/C,MAAM,IAAI,CAAC,CAAC;IACd,CAAC;IAED,MAAM,cAAc,GAAG,eAAe,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvD,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC;IAE7B,MAAM,aAAa,GAAG,eAAe,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAEtD,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,MAAM,WAAW,GAAG,eAAe,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;IAC1D,QAAQ,GAAG,WAAW,CAAC,IAAI,CAAC;IAC5B,MAAM,gBAAgB,GAAG,eAAe,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;IAC/D,QAAQ,GAAG,gBAAgB,CAAC,IAAI,CAAC;IAEjC,IAAI,YAAgC,CAAC;IACrC,IAAI,YAAgC,CAAC;IACrC,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;QACpB,IAAI,QAAQ,GAAG,CAAC,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC;YACrC,OAAO,CAAC,oCAAoC,CAAC,CAAC;QAChD,CAAC;QACD,YAAY,GAAG,UAAU,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;QACjD,YAAY,GAAG,UAAU,CAAC,YAAY,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC;IACvD,CAAC;IAED,OAAO;QACL,UAAU;QACV,OAAO,EAAE,eAAe,CAAC,WAAW,CAAC,KAAK,CAAC;QAC3C,YAAY,EAAE,YAAY,CAAC,gBAAgB,CAAC,KAAK,CAAC;QAClD,UAAU,EAAE,eAAe,CAAC,cAAc,CAAC,KAAK,CAAC;QACjD,SAAS,EAAE,aAAa,CAAC,KAAK;QAC9B,YAAY;QACZ,YAAY;QACZ,YAAY;QACZ,YAAY;KACb,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,aAAa,CACpB,OAAe,EACf,IAAuB,EACvB,GAAa;IAEb,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,KAAK,IAAI,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,MAAM,IAAI,UAAU,EAAE,CAAC;QACnE,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,CAAC;QAC5D,MAAM,MAAM,GAAG,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;QACrC,MAAM,CAAC,UAAU,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC;QACnC,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACtC,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;QACjE,MAAM,IAAI,CAAC,CAAC;IACd,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAS,oBAAoB,CAC3B,GAAW,EACX,iBAAyB,EACzB,gBAAwB,EACxB,UAAkB,EAClB,IAAuB;IAEvB,MAAM,WAAW,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,iBAAiB,CAAC,CAAC;IACvD,MAAM,UAAU,GAAG,GAAG,CAAC,QAAQ,CAAC,gBAAgB,EAAE,UAAU,CAAC,CAAC;IAC9D,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC;IACnD,IAAI,CAAC,aAAa,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAC;IAE5D,MAAM,YAAY,GAAa,EAAE,CAAC;IAClC,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,IAAI,aAAa,CAAC,WAAW,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC;IACxD,KAAK,IAAI,aAAa,CAAC,UAAU,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC;IACvD,KAAK,IAAI,aAAa,CAAC,IAAI,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC;IAEjD,MAAM,GAAG,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;IAC7B,MAAM,MAAM,GAAG,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;IACrC,MAAM,CAAC,UAAU,CAAC,gBAAgB,EAAE,CAAC,CAAC,CAAC;IACvC,MAAM,CAAC,aAAa,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAC/B,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACnB,KAAK,MAAM,MAAM,IAAI,YAAY,EAAE,CAAC;QAClC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACrB,CAAC;IACD,OAAO,GAAG,CAAC,MAAM,EAAE,CAAC;AACtB,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,OAAe;IAEf,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,OAAO,CAAC,CAAC;IACpC,IAAI,GAAG,CAAC,MAAM,GAAG,aAAa,EAAE,CAAC;QAC/B,OAAO,CAAC,eAAe,aAAa,QAAQ,CAAC,CAAC;IAChD,CAAC;IAED,MAAM,EAAE,UAAU,EAAE,gBAAgB,EAAE,cAAc,EAAE,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;IACvE,MAAM,EAAE,iBAAiB,EAAE,KAAK,EAAE,GAAG,gBAAgB,CACnD,GAAG,EACH,gBAAgB,EAChB,UAAU,EACV,cAAc,CACf,CAAC;IACF,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;IACnD,MAAM,MAAM,GAAG,WAAW,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IAE1C,IAAI,MAAM,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACnC,OAAO,CAAC,4BAA4B,CAAC,CAAC;IACxC,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU;SAC/B,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,OAAO,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;SACpE,MAAM,CACL,CAAC,KAAK,EAA4D,EAAE,CAClE,KAAK,CAAC,IAAI,KAAK,IAAI,CACtB;SACA,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC;SACjD,EAAE,CAAC,CAAC,CAAC,CAAC;IACT,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,CAAC,4CAA4C,CAAC,CAAC;IACxD,CAAC;IAED,MAAM,SAAS,GAAG,eAAe,CAAC;QAChC,GAAG,EAAE,MAAM,CAAC,SAAS;QACrB,MAAM,EAAE,KAAK;QACb,IAAI,EAAE,MAAM;KACb,CAAC,CAAC;IACH,IACE,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CACnB,MAAM,CAAC,UAAU,EACjB,SAAS,EACT,QAAQ,CAAC,SAAS,CAAC,OAAO,CAC3B,EACD,CAAC;QACD,OAAO,CAAC,2CAA2C,CAAC,CAAC;IACvD,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IACpC,MAAM,aAAa,GAAG,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC;IAC1D,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;IACvE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC;QACvC,OAAO,CAAC,iEAAiE,CAAC,CAAC;IAC7E,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IACtD,MAAM,SAAS,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IACtD,IACE,MAAM,CAAC,MAAM,KAAK,SAAS,CAAC,MAAM;QAClC,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC,EAAE,KAAK,SAAS,CAAC,KAAK,CAAC,CAAC,EACnD,CAAC;QACD,OAAO,CAAC,6CAA6C,CAAC,CAAC;IACzD,CAAC;IAED,MAAM,cAAc,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CACxC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,QAAQ,CAAC,SAAS,CAAC,MAAM,CAC9C,CAAC;IACF,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,OAAO,CAAC,8CAA8C,CAAC,CAAC;IAC1D,CAAC;IACD,MAAM,QAAQ,GAAG,oBAAoB,CACnC,GAAG,EACH,iBAAiB,EACjB,gBAAgB,EAChB,UAAU,EACV,QAAQ,CAAC,IAAI,CAAC,IAAI,CACnB,CAAC;IACF,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,EAAE,CAAC;QAC7C,OAAO,CAAC,4DAA4D,CAAC,CAAC;IACxE,CAAC;IAED,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;QACpB,IACE,MAAM,CAAC,YAAY,KAAK,MAAM,CAAC,YAAY;YAC3C,MAAM,CAAC,YAAY,KAAK,MAAM,CAAC,YAAY,EAC3C,CAAC;YACD,OAAO,CAAC,kDAAkD,CAAC,CAAC;QAC9D,CAAC;IACH,CAAC;IAED,MAAM,YAAY,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACrE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,CAAC;AAClD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,OAAe,EACf,oBAA4B;IAE5B,MAAM,EAAE,YAAY,EAAE,GAAG,MAAM,qBAAqB,CAAC,OAAO,CAAC,CAAC;IAC9D,MAAM,QAAQ,GAAG,oBAAoB,CAAC,WAAW,EAAE,CAAC;IACpD,IAAI,YAAY,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,IAAI,oBAAoB,CAC5B,kEAAkE,EAClE,EAAE,oBAAoB,EAAE,QAAQ,EAAE,kBAAkB,EAAE,YAAY,EAAE,CACrE,CAAC;IACJ,CAAC;AACH,CAAC","sourcesContent":["// Implemented from the public AOSP APK Signature Scheme v2/v3 spec.\n/**\n * Pure-JS verifier for APK Signature Scheme v2 and v3.\n *\n * This proves an APK was cryptographically SIGNED by a given key — it verifies\n * the signature over the recomputed APK content digest, then binds the leaf\n * certificate to that key. It is NOT a cert-presence check: reading a\n * certificate out of an APK proves nothing (certificates are public), so this\n * module recomputes the content digest and verifies the signature before\n * trusting the signer. Used to gate `am instrument` against a helper that could\n * otherwise be a package-name squatter.\n *\n * No external dependencies, no Android SDK, no shelling out — `node:crypto` and\n * `node:fs` only, so it runs on any consumer machine that runs the MCP server.\n */\n\nimport {\n createHash,\n createPublicKey,\n verify as cryptoVerify,\n X509Certificate,\n} from 'node:crypto';\nimport { readFile } from 'node:fs/promises';\n\nimport { SnapshotHelperError, UntrustedHelperError } from './errors.js';\n\nexport type ApkSignerVerification = {\n verified: true;\n signerSha256: string;\n scheme: 'v2' | 'v3';\n};\n\nconst MAX_APK_BYTES = 200 * 1024 * 1024;\n\nconst EOCD_SIGNATURE = 0x0605_4b50;\nconst EOCD_MIN_SIZE = 22;\nconst EOCD_CD_OFFSET_FIELD = 16;\nconst EOCD_COMMENT_LENGTH_FIELD = 20;\n\nconst SIG_BLOCK_MAGIC = Buffer.from('APK Sig Block 42', 'latin1');\nconst SIG_BLOCK_FOOTER_SIZE = 24;\n\nconst V2_BLOCK_ID = 0x7109_871a;\nconst V3_BLOCK_ID = 0xf053_68c0;\n\nconst CHUNK_SIZE = 1024 * 1024;\nconst CHUNK_PREFIX = 0xa5;\nconst TOP_LEVEL_PREFIX = 0x5a;\n\ntype ContentDigestAlgo = 'sha256' | 'sha512';\n\ntype SigAlgo = {\n hash: ContentDigestAlgo;\n verify: (\n data: Buffer,\n key: ReturnType<typeof createPublicKey>,\n signature: Buffer,\n ) => boolean;\n strength: number;\n};\n\nconst RSA_PKCS1_PSS_PADDING = 6;\n\n/**\n * Map an APK signature-algorithm ID to its hash and verification strategy.\n * IDs and their crypto meanings are fixed by the AOSP spec.\n *\n * @param id - The 32-bit signature algorithm identifier.\n * @returns The algorithm descriptor, or null when unsupported (e.g. fs-verity).\n */\nfunction sigAlgo(id: number): SigAlgo | null {\n switch (id) {\n case 0x0101:\n return {\n hash: 'sha256',\n strength: 1,\n verify: (data, key, signature) =>\n cryptoVerify(\n 'sha256',\n data,\n { key, padding: RSA_PKCS1_PSS_PADDING, saltLength: 32 },\n signature,\n ),\n };\n case 0x0102:\n return {\n hash: 'sha512',\n strength: 3,\n verify: (data, key, signature) =>\n cryptoVerify(\n 'sha512',\n data,\n { key, padding: RSA_PKCS1_PSS_PADDING, saltLength: 64 },\n signature,\n ),\n };\n case 0x0103:\n return {\n hash: 'sha256',\n strength: 0,\n verify: (data, key, signature) =>\n cryptoVerify('sha256', data, key, signature),\n };\n case 0x0104:\n return {\n hash: 'sha512',\n strength: 2,\n verify: (data, key, signature) =>\n cryptoVerify('sha512', data, key, signature),\n };\n case 0x0201:\n return {\n hash: 'sha256',\n strength: 1,\n verify: (data, key, signature) =>\n cryptoVerify('sha256', data, key, signature),\n };\n case 0x0202:\n return {\n hash: 'sha512',\n strength: 3,\n verify: (data, key, signature) =>\n cryptoVerify('sha512', data, key, signature),\n };\n case 0x0301:\n return {\n hash: 'sha256',\n strength: 1,\n verify: (data, key, signature) =>\n cryptoVerify('sha256', data, key, signature),\n };\n default:\n return null;\n }\n}\n\n/**\n * Raise an {@link SnapshotHelperError} for any malformed or unverifiable APK.\n *\n * @param message - What went wrong.\n * @throws Always.\n */\nfunction invalid(message: string): never {\n throw new SnapshotHelperError('ARTIFACT_INVALID', message);\n}\n\n/**\n * Find the ZIP End Of Central Directory record by scanning backwards.\n *\n * @param apk - The whole APK file.\n * @returns The EOCD start offset plus the central-directory offset and size it\n * declares.\n */\nfunction findEocd(apk: Buffer): {\n eocdOffset: number;\n centralDirOffset: number;\n centralDirSize: number;\n} {\n const minStart = Math.max(0, apk.length - EOCD_MIN_SIZE - 0xffff);\n for (let i = apk.length - EOCD_MIN_SIZE; i >= minStart; i -= 1) {\n if (apk.readUInt32LE(i) !== EOCD_SIGNATURE) {\n continue;\n }\n const commentLength = apk.readUInt16LE(i + EOCD_COMMENT_LENGTH_FIELD);\n if (i + EOCD_MIN_SIZE + commentLength !== apk.length) {\n continue;\n }\n return {\n eocdOffset: i,\n centralDirOffset: apk.readUInt32LE(i + EOCD_CD_OFFSET_FIELD),\n centralDirSize: apk.readUInt32LE(i + 12),\n };\n }\n return invalid('APK is missing a ZIP End Of Central Directory record');\n}\n\n/**\n * Locate the APK Signing Block that sits immediately before the central\n * directory.\n *\n * @param apk - The whole APK file.\n * @param centralDirOffset - The central-directory offset from the EOCD.\n * @param eocdOffset - The EOCD start offset.\n * @param centralDirSize - The central-directory size from the EOCD.\n * @returns The signing-block offset and its payload (between the two size\n * fields, excluding the trailing size and magic).\n */\nfunction findSigningBlock(\n apk: Buffer,\n centralDirOffset: number,\n eocdOffset: number,\n centralDirSize: number,\n): { apkSigBlockOffset: number; pairs: Buffer } {\n if (centralDirOffset + centralDirSize !== eocdOffset) {\n invalid('Central directory is not immediately followed by the EOCD');\n }\n if (centralDirOffset < SIG_BLOCK_FOOTER_SIZE) {\n invalid('No APK Signing Block before the central directory');\n }\n const footer = apk.subarray(\n centralDirOffset - SIG_BLOCK_FOOTER_SIZE,\n centralDirOffset,\n );\n if (!footer.subarray(8, 24).equals(SIG_BLOCK_MAGIC)) {\n invalid('APK Signing Block magic not found (APK is not v2/v3 signed)');\n }\n const trailerSize = footer.readBigUInt64LE(0);\n const totalSize = Number(trailerSize) + 8;\n const apkSigBlockOffset = centralDirOffset - totalSize;\n if (apkSigBlockOffset < 0) {\n invalid('APK Signing Block size is out of range');\n }\n const headerSize = apk.readBigUInt64LE(apkSigBlockOffset);\n if (headerSize !== trailerSize) {\n invalid('APK Signing Block header/footer size mismatch');\n }\n const pairs = apk.subarray(\n apkSigBlockOffset + 8,\n centralDirOffset - SIG_BLOCK_FOOTER_SIZE,\n );\n return { apkSigBlockOffset, pairs };\n}\n\n/**\n * Extract a scheme block (v2 or v3) from the signing block's ID/value pairs.\n *\n * @param pairs - The concatenated ID/value pairs.\n * @returns The v3 block if present, else the v2 block, with its scheme label.\n */\nfunction selectSchemeBlock(pairs: Buffer): {\n scheme: 'v2' | 'v3';\n block: Buffer;\n} {\n const blocks = new Map<number, Buffer>();\n let cursor = 0;\n while (cursor + 8 <= pairs.length) {\n const pairSize = Number(pairs.readBigUInt64LE(cursor));\n cursor += 8;\n if (pairSize < 4 || cursor + pairSize > pairs.length) {\n break;\n }\n const id = pairs.readUInt32LE(cursor);\n const value = pairs.subarray(cursor + 4, cursor + pairSize);\n blocks.set(id, value);\n cursor += pairSize;\n }\n const v3 = blocks.get(V3_BLOCK_ID);\n if (v3) {\n return { scheme: 'v3', block: v3 };\n }\n const v2 = blocks.get(V2_BLOCK_ID);\n if (v2) {\n return { scheme: 'v2', block: v2 };\n }\n return invalid('No v2 or v3 signature block in the APK Signing Block');\n}\n\n/**\n * Read a `uint32`-length-prefixed slice, advancing the cursor past it.\n *\n * @param bytes - The buffer to read from.\n * @param cursor - The current read offset.\n * @returns The inner bytes and the offset just past them.\n */\nfunction readLenPrefixed(\n bytes: Buffer,\n cursor: number,\n): { value: Buffer; next: number } {\n if (cursor + 4 > bytes.length) {\n invalid('Truncated length-prefixed field');\n }\n const len = bytes.readUInt32LE(cursor);\n const start = cursor + 4;\n const end = start + len;\n if (end > bytes.length) {\n invalid('Length-prefixed field runs past end of block');\n }\n return { value: bytes.subarray(start, end), next: end };\n}\n\n/**\n * Split a length-prefixed sequence into its length-prefixed elements.\n *\n * @param seq - The sequence bytes (already unwrapped from its own prefix).\n * @returns The element buffers in order.\n */\nfunction readSequence(seq: Buffer): Buffer[] {\n const items: Buffer[] = [];\n let cursor = 0;\n while (cursor < seq.length) {\n const { value, next } = readLenPrefixed(seq, cursor);\n items.push(value);\n cursor = next;\n }\n return items;\n}\n\ntype SignedDataElement = { algoId: number; payload: Buffer };\n\n/**\n * Parse the `{ uint32 algoId; len-prefixed payload }` records used by both the\n * signed-data digests list and the signatures list.\n *\n * @param seq - The sequence bytes.\n * @returns The parsed algorithm-id/payload records in order.\n */\nfunction readAlgoRecords(seq: Buffer): SignedDataElement[] {\n return readSequence(seq).map((element) => {\n if (element.length < 4) {\n invalid('Algorithm record too short');\n }\n return {\n algoId: element.readUInt32LE(0),\n payload: readLenPrefixed(element, 4).value,\n };\n });\n}\n\ntype ParsedSigner = {\n signedData: Buffer;\n digests: SignedDataElement[];\n certificates: Buffer[];\n signatures: SignedDataElement[];\n publicKey: Buffer;\n signedMinSdk?: number;\n signedMaxSdk?: number;\n signerMinSdk?: number;\n signerMaxSdk?: number;\n};\n\n/**\n * Parse a single signer out of a v2/v3 scheme block.\n *\n * @param block - The scheme block value (a length-prefixed signers sequence).\n * @param scheme - Which scheme is being parsed (v3 carries extra SDK fields).\n * @returns The parsed signer.\n */\nfunction parseSigner(block: Buffer, scheme: 'v2' | 'v3'): ParsedSigner {\n const signers = readSequence(readLenPrefixed(block, 0).value);\n if (signers.length !== 1) {\n invalid(`Expected exactly one signer, found ${signers.length}`);\n }\n const signer = signers[0];\n\n let cursor = 0;\n const signedDataRead = readLenPrefixed(signer, cursor);\n const signedData = signedDataRead.value;\n cursor = signedDataRead.next;\n\n let signerMinSdk: number | undefined;\n let signerMaxSdk: number | undefined;\n if (scheme === 'v3') {\n if (cursor + 8 > signer.length) {\n invalid('v3 signer missing min/max SDK');\n }\n signerMinSdk = signer.readUInt32LE(cursor);\n signerMaxSdk = signer.readUInt32LE(cursor + 4);\n cursor += 8;\n }\n\n const signaturesRead = readLenPrefixed(signer, cursor);\n cursor = signaturesRead.next;\n\n const publicKeyRead = readLenPrefixed(signer, cursor);\n\n let sdCursor = 0;\n const digestsRead = readLenPrefixed(signedData, sdCursor);\n sdCursor = digestsRead.next;\n const certificatesRead = readLenPrefixed(signedData, sdCursor);\n sdCursor = certificatesRead.next;\n\n let signedMinSdk: number | undefined;\n let signedMaxSdk: number | undefined;\n if (scheme === 'v3') {\n if (sdCursor + 8 > signedData.length) {\n invalid('v3 signed-data missing min/max SDK');\n }\n signedMinSdk = signedData.readUInt32LE(sdCursor);\n signedMaxSdk = signedData.readUInt32LE(sdCursor + 4);\n }\n\n return {\n signedData,\n digests: readAlgoRecords(digestsRead.value),\n certificates: readSequence(certificatesRead.value),\n signatures: readAlgoRecords(signaturesRead.value),\n publicKey: publicKeyRead.value,\n signedMinSdk,\n signedMaxSdk,\n signerMinSdk,\n signerMaxSdk,\n };\n}\n\n/**\n * Compute the chunked content digest of one segment, appending each chunk's\n * digest to `out`.\n *\n * @param segment - The segment bytes.\n * @param hash - The digest algorithm.\n * @param out - Accumulator for per-chunk digests.\n * @returns The number of chunks produced.\n */\nfunction digestSegment(\n segment: Buffer,\n hash: ContentDigestAlgo,\n out: Buffer[],\n): number {\n let chunks = 0;\n for (let offset = 0; offset < segment.length; offset += CHUNK_SIZE) {\n const chunk = segment.subarray(offset, offset + CHUNK_SIZE);\n const prefix = Buffer.allocUnsafe(5);\n prefix.writeUInt8(CHUNK_PREFIX, 0);\n prefix.writeUInt32LE(chunk.length, 1);\n out.push(createHash(hash).update(prefix).update(chunk).digest());\n chunks += 1;\n }\n return chunks;\n}\n\n/**\n * Recompute the APK content digest per the v2/v3 spec: chunk the pre-signing\n * bytes, the central directory, and the EOCD (patched so its central-directory\n * offset points at the signing block), then hash the concatenated chunk\n * digests.\n *\n * @param apk - The whole APK file.\n * @param apkSigBlockOffset - Offset of the APK Signing Block.\n * @param centralDirOffset - Central-directory offset.\n * @param eocdOffset - EOCD start offset.\n * @param hash - The content-digest algorithm.\n * @returns The recomputed content digest.\n */\nfunction computeContentDigest(\n apk: Buffer,\n apkSigBlockOffset: number,\n centralDirOffset: number,\n eocdOffset: number,\n hash: ContentDigestAlgo,\n): Buffer {\n const beforeBlock = apk.subarray(0, apkSigBlockOffset);\n const centralDir = apk.subarray(centralDirOffset, eocdOffset);\n const eocd = Buffer.from(apk.subarray(eocdOffset));\n eocd.writeUInt32LE(apkSigBlockOffset, EOCD_CD_OFFSET_FIELD);\n\n const chunkDigests: Buffer[] = [];\n let count = 0;\n count += digestSegment(beforeBlock, hash, chunkDigests);\n count += digestSegment(centralDir, hash, chunkDigests);\n count += digestSegment(eocd, hash, chunkDigests);\n\n const top = createHash(hash);\n const header = Buffer.allocUnsafe(5);\n header.writeUInt8(TOP_LEVEL_PREFIX, 0);\n header.writeUInt32LE(count, 1);\n top.update(header);\n for (const digest of chunkDigests) {\n top.update(digest);\n }\n return top.digest();\n}\n\n/**\n * Verify an APK's v2/v3 signature and return its leaf signer certificate\n * SHA-256.\n *\n * @param apkPath - Path to the APK on disk.\n * @returns The verified scheme and lowercase-hex leaf certificate SHA-256.\n * @throws {@link SnapshotHelperError} when the APK is malformed or the\n * signature does not verify.\n */\nexport async function verifyApkSignerSha256(\n apkPath: string,\n): Promise<ApkSignerVerification> {\n const apk = await readFile(apkPath);\n if (apk.length > MAX_APK_BYTES) {\n invalid(`APK exceeds ${MAX_APK_BYTES} bytes`);\n }\n\n const { eocdOffset, centralDirOffset, centralDirSize } = findEocd(apk);\n const { apkSigBlockOffset, pairs } = findSigningBlock(\n apk,\n centralDirOffset,\n eocdOffset,\n centralDirSize,\n );\n const { scheme, block } = selectSchemeBlock(pairs);\n const signer = parseSigner(block, scheme);\n\n if (signer.certificates.length < 1) {\n invalid('Signer has no certificates');\n }\n\n const selected = signer.signatures\n .map((signature) => ({ signature, algo: sigAlgo(signature.algoId) }))\n .filter(\n (entry): entry is { signature: SignedDataElement; algo: SigAlgo } =>\n entry.algo !== null,\n )\n .sort((a, b) => b.algo.strength - a.algo.strength)\n .at(0);\n if (!selected) {\n invalid('No supported signature algorithm in signer');\n }\n\n const publicKey = createPublicKey({\n key: signer.publicKey,\n format: 'der',\n type: 'spki',\n });\n if (\n !selected.algo.verify(\n signer.signedData,\n publicKey,\n selected.signature.payload,\n )\n ) {\n invalid('Signature did not verify over signed-data');\n }\n\n const leaf = signer.certificates[0];\n const certPublicKey = new X509Certificate(leaf).publicKey;\n const certSpki = certPublicKey.export({ format: 'der', type: 'spki' });\n if (!certSpki.equals(signer.publicKey)) {\n invalid('Leaf certificate public key does not match the block public key');\n }\n\n const sigIds = signer.signatures.map((s) => s.algoId);\n const digestIds = signer.digests.map((d) => d.algoId);\n if (\n sigIds.length !== digestIds.length ||\n sigIds.some((id, index) => id !== digestIds[index])\n ) {\n invalid('Signature and digest algorithm lists differ');\n }\n\n const expectedDigest = signer.digests.find(\n (d) => d.algoId === selected.signature.algoId,\n );\n if (!expectedDigest) {\n invalid('No content digest for the selected algorithm');\n }\n const computed = computeContentDigest(\n apk,\n apkSigBlockOffset,\n centralDirOffset,\n eocdOffset,\n selected.algo.hash,\n );\n if (!computed.equals(expectedDigest.payload)) {\n invalid('Recomputed APK content digest does not match signed digest');\n }\n\n if (scheme === 'v3') {\n if (\n signer.signerMinSdk !== signer.signedMinSdk ||\n signer.signerMaxSdk !== signer.signedMaxSdk\n ) {\n invalid('v3 signer min/max SDK does not match signed-data');\n }\n }\n\n const signerSha256 = createHash('sha256').update(leaf).digest('hex');\n return { verified: true, signerSha256, scheme };\n}\n\n/**\n * Verify an APK and assert its signer matches an expected pinned SHA-256.\n *\n * @param apkPath - Path to the APK on disk.\n * @param expectedSignerSha256 - The pinned lowercase-hex signer SHA-256.\n * @throws {@link UntrustedHelperError} when the (validly signed) APK's signer\n * does not match the pin.\n * @throws {@link SnapshotHelperError} when the APK is malformed or unverifiable.\n */\nexport async function assertApkSignerSha256(\n apkPath: string,\n expectedSignerSha256: string,\n): Promise<void> {\n const { signerSha256 } = await verifyApkSignerSha256(apkPath);\n const expected = expectedSignerSha256.toLowerCase();\n if (signerSha256 !== expected) {\n throw new UntrustedHelperError(\n 'Installed snapshot helper is signed by an unexpected certificate',\n { expectedSignerSha256: expected, actualSignerSha256: signerSha256 },\n );\n }\n}\n"]}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Parser for the in-repo Android snapshot-helper instrumentation.
|
|
4
|
+
*
|
|
5
|
+
* The stock `uiautomator dump` calls `UiAutomation.waitForIdle(1000, 10000)`,
|
|
6
|
+
* which never returns on a continuously-redrawing React Native screen (the
|
|
7
|
+
* MetaMask homepage emits accessibility events at ~10Hz indefinitely). The
|
|
8
|
+
* helper APK (`io.metamask.devicemcp.snapshothelper/.SnapshotInstrumentation`)
|
|
9
|
+
* skips that idle wait and streams the hierarchy back as chunked base64 over
|
|
10
|
+
* `am instrument` status records.
|
|
11
|
+
*
|
|
12
|
+
* This module contains only the pure parsing of that output so it can be tested
|
|
13
|
+
* without a device. The orchestration (install, invoke, fall back) lives in the
|
|
14
|
+
* ADB backend.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.HELPER_INSTRUMENTATION = exports.HELPER_PACKAGE = exports.HELPER_PROTOCOL = void 0;
|
|
18
|
+
exports.parseInstrumentationOutput = parseInstrumentationOutput;
|
|
19
|
+
exports.reassembleInstrumentationXml = reassembleInstrumentationXml;
|
|
20
|
+
/** Emitted as `agentDeviceProtocol` in every status and result record. */
|
|
21
|
+
exports.HELPER_PROTOCOL = 'device-mcp-snapshot-helper-v1';
|
|
22
|
+
/** The instrumentation component `am instrument` targets. */
|
|
23
|
+
exports.HELPER_PACKAGE = 'io.metamask.devicemcp.snapshothelper';
|
|
24
|
+
/** Fully-qualified instrumentation runner name. */
|
|
25
|
+
exports.HELPER_INSTRUMENTATION = `${exports.HELPER_PACKAGE}/.SnapshotInstrumentation`;
|
|
26
|
+
/**
|
|
27
|
+
* Parse a value out of a raw `am instrument` line.
|
|
28
|
+
*
|
|
29
|
+
* @param line - A single output line, already stripped of its trailing CR.
|
|
30
|
+
* @param prefix - The banner prefix (`INSTRUMENTATION_STATUS: ` etc.).
|
|
31
|
+
* @returns The `key`/`value` pair, or null when the line is not a `key=value`.
|
|
32
|
+
*/
|
|
33
|
+
function parseKeyValue(line, prefix) {
|
|
34
|
+
if (!line.startsWith(prefix)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const body = line.slice(prefix.length);
|
|
38
|
+
const eq = body.indexOf('=');
|
|
39
|
+
if (eq === -1) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return { key: body.slice(0, eq), value: body.slice(eq + 1) };
|
|
43
|
+
}
|
|
44
|
+
const STATUS_PREFIX = 'INSTRUMENTATION_STATUS: ';
|
|
45
|
+
const RESULT_PREFIX = 'INSTRUMENTATION_RESULT: ';
|
|
46
|
+
const STATUS_CODE_PREFIX = 'INSTRUMENTATION_STATUS_CODE:';
|
|
47
|
+
const RESULT_CODE_PREFIX = 'INSTRUMENTATION_CODE:';
|
|
48
|
+
/**
|
|
49
|
+
* `INSTRUMENTATION_CODE: -1` is `Activity.RESULT_FIRST_USER`, the helper's
|
|
50
|
+
* success sentinel. Any other terminal code means the run did not finish
|
|
51
|
+
* cleanly.
|
|
52
|
+
*/
|
|
53
|
+
const SUCCESS_INSTRUMENTATION_CODE = '-1';
|
|
54
|
+
/**
|
|
55
|
+
* Split raw `am instrument -w` stdout into chunks, result, and terminal code.
|
|
56
|
+
*
|
|
57
|
+
* Handles the CRLF line endings adb injects and tolerates duplicate or
|
|
58
|
+
* interleaved status records by keying chunks on their advertised index.
|
|
59
|
+
*
|
|
60
|
+
* @param stdout - Raw stdout from `adb shell am instrument -w ...`.
|
|
61
|
+
* @returns The grouped records.
|
|
62
|
+
*/
|
|
63
|
+
function parseInstrumentationOutput(stdout) {
|
|
64
|
+
const chunks = new Map();
|
|
65
|
+
const result = new Map();
|
|
66
|
+
let chunkCount = null;
|
|
67
|
+
let instrumentationCode = null;
|
|
68
|
+
// adb terminates lines with CRLF; the trailing CR would corrupt base64.
|
|
69
|
+
const lines = stdout.replace(/\r/gu, '').split('\n');
|
|
70
|
+
let pendingChunkIndex = null;
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const status = parseKeyValue(line, STATUS_PREFIX);
|
|
73
|
+
if (status) {
|
|
74
|
+
if (status.key === 'chunkIndex') {
|
|
75
|
+
const index = Number.parseInt(status.value, 10);
|
|
76
|
+
pendingChunkIndex = Number.isNaN(index) ? null : index;
|
|
77
|
+
}
|
|
78
|
+
else if (status.key === 'chunkCount') {
|
|
79
|
+
const count = Number.parseInt(status.value, 10);
|
|
80
|
+
if (!Number.isNaN(count)) {
|
|
81
|
+
chunkCount = count;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (status.key === 'payloadBase64' && pendingChunkIndex !== null) {
|
|
85
|
+
chunks.set(pendingChunkIndex, status.value);
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const resultPair = parseKeyValue(line, RESULT_PREFIX);
|
|
90
|
+
if (resultPair) {
|
|
91
|
+
result.set(resultPair.key, resultPair.value);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (line.startsWith(STATUS_CODE_PREFIX)) {
|
|
95
|
+
// A status record has ended; the next chunk needs its own index line.
|
|
96
|
+
pendingChunkIndex = null;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (line.startsWith(RESULT_CODE_PREFIX)) {
|
|
100
|
+
instrumentationCode = line.slice(RESULT_CODE_PREFIX.length).trim();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { chunks, chunkCount, result, instrumentationCode };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Reassemble the hierarchy XML from an `am instrument` run.
|
|
107
|
+
*
|
|
108
|
+
* Each chunk is an independently valid base64 encoding of a raw byte slice
|
|
109
|
+
* (`Base64.NO_WRAP`), so the chunks must be decoded individually and their
|
|
110
|
+
* bytes concatenated. Concatenating the base64 strings first and decoding once
|
|
111
|
+
* corrupts the payload wherever a slice length is not a multiple of three.
|
|
112
|
+
*
|
|
113
|
+
* @param stdout - Raw stdout from `adb shell am instrument -w ...`.
|
|
114
|
+
* @returns The reassembled hierarchy XML.
|
|
115
|
+
* @throws If the helper reported failure, chunks are missing, or the payload is
|
|
116
|
+
* not complete hierarchy XML.
|
|
117
|
+
*/
|
|
118
|
+
function reassembleInstrumentationXml(stdout) {
|
|
119
|
+
const { chunks, chunkCount, result, instrumentationCode } = parseInstrumentationOutput(stdout);
|
|
120
|
+
if (result.size === 0) {
|
|
121
|
+
// No `INSTRUMENTATION_RESULT:` record at all: `am` was killed mid-stream
|
|
122
|
+
// or the runner crashed before finishing. Distinct from a clean failure.
|
|
123
|
+
throw new Error('Snapshot helper produced no result record (the instrumentation was ' +
|
|
124
|
+
'interrupted before it finished).');
|
|
125
|
+
}
|
|
126
|
+
if (result.get('ok') !== 'true') {
|
|
127
|
+
const message = result.get('message');
|
|
128
|
+
const errorType = result.get('errorType');
|
|
129
|
+
const detail = [errorType, message].filter(Boolean).join(': ');
|
|
130
|
+
throw new Error(`Snapshot helper reported failure${detail ? ` (${detail})` : ''}.`);
|
|
131
|
+
}
|
|
132
|
+
if (instrumentationCode !== null &&
|
|
133
|
+
instrumentationCode !== SUCCESS_INSTRUMENTATION_CODE) {
|
|
134
|
+
throw new Error(`Snapshot helper ended with code ${instrumentationCode} (expected ${SUCCESS_INSTRUMENTATION_CODE}).`);
|
|
135
|
+
}
|
|
136
|
+
const expectedCount = chunkCount ?? chunks.size;
|
|
137
|
+
if (expectedCount === 0) {
|
|
138
|
+
throw new Error('Snapshot helper produced no hierarchy chunks.');
|
|
139
|
+
}
|
|
140
|
+
const parts = [];
|
|
141
|
+
const missing = [];
|
|
142
|
+
for (let index = 0; index < expectedCount; index += 1) {
|
|
143
|
+
const payload = chunks.get(index);
|
|
144
|
+
if (payload === undefined) {
|
|
145
|
+
missing.push(index);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
parts.push(Buffer.from(payload, 'base64'));
|
|
149
|
+
}
|
|
150
|
+
if (missing.length > 0) {
|
|
151
|
+
throw new Error(`Snapshot helper output was missing ${missing.length} of ${expectedCount} chunks (indices ${missing.slice(0, 10).join(', ')}${missing.length > 10 ? ', …' : ''}).`);
|
|
152
|
+
}
|
|
153
|
+
const xml = Buffer.concat(parts).toString('utf8');
|
|
154
|
+
if (!xml.includes('<hierarchy') || !xml.includes('</hierarchy>')) {
|
|
155
|
+
throw new Error('Snapshot helper output did not contain complete hierarchy XML.');
|
|
156
|
+
}
|
|
157
|
+
return xml;
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=snapshot.cjs.map
|