apk-signing.js
13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
import _ from 'lodash';
import _fs from 'fs';
import { exec } from 'teen_process';
import path from 'path';
import log from '../logger.js';
import { tempDir, system, mkdirp, fs, zip } from 'appium-support';
import {
getJavaForOs, getApksignerForOs, getJavaHome,
rootDir, APKS_EXTENSION, unsignApk,
} from '../helpers.js';
import { quote } from 'shell-quote';
const DEFAULT_PRIVATE_KEY = path.resolve(rootDir, 'keys', 'testkey.pk8');
const DEFAULT_CERTIFICATE = path.resolve(rootDir, 'keys', 'testkey.x509.pem');
const DEFAULT_CERT_DIGEST = 'a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc';
const BUNDLETOOL_TUTORIAL = 'https://developer.android.com/studio/command-line/bundletool';
const APKSIGNER_VERIFY_FAIL = 'DOES NOT VERIFY';
let apkSigningMethods = {};
/**
* Execute apksigner utility with given arguments.
*
* @param {?Array<String>} args - The list of tool arguments.
* @return {string} - Command stdout
* @throws {Error} If apksigner binary is not present on the local file system
* or the return code is not equal to zero.
*/
apkSigningMethods.executeApksigner = async function executeApksigner (args = []) {
const apkSignerJar = await getApksignerForOs(this);
const fullCmd = [
getJavaForOs(), '-Xmx1024M', '-Xss1m',
'-jar', apkSignerJar,
...args
];
log.debug(`Starting apksigner: ${quote(fullCmd)}`);
const {stdout, stderr} = await exec(fullCmd[0], fullCmd.slice(1));
for (let [name, stream] of [['stdout', stdout], ['stderr', stderr]]) {
if (!_.trim(stream)) {
continue;
}
if (name === 'stdout') {
// Make the output less talkative
stream = stream.split('\n')
.filter((line) => !line.includes('WARNING:'))
.join('\n');
}
log.debug(`apksigner ${name}: ${stream}`);
}
return stdout;
};
/**
* (Re)sign the given apk file on the local file system with the default certificate.
*
* @param {string} apk - The full path to the local apk file.
* @throws {Error} If signing fails.
*/
apkSigningMethods.signWithDefaultCert = async function signWithDefaultCert (apk) {
log.debug(`Signing '${apk}' with default cert`);
if (!(await fs.exists(apk))) {
throw new Error(`${apk} file doesn't exist.`);
}
try {
const args = ['sign',
'--key', DEFAULT_PRIVATE_KEY,
'--cert', DEFAULT_CERTIFICATE,
apk];
await this.executeApksigner(args);
} catch (err) {
log.warn(`Cannot use apksigner tool for signing. Defaulting to sign.jar. ` +
`Original error: ${err.stderr || err.message}`);
const signPath = path.resolve(this.helperJarPath, 'sign.jar');
const fullCmd = [getJavaForOs(), '-jar', signPath, apk, '--override'];
log.debug(`Starting sign.jar: ${quote(fullCmd)}`);
try {
await exec(fullCmd[0], fullCmd.slice(1));
} catch (e) {
throw new Error(`Could not sign with default certificate. ` +
`Original error ${e.stderr || e.message}`);
}
}
};
/**
* (Re)sign the given apk file on the local file system with a custom certificate.
*
* @param {string} apk - The full path to the local apk file.
* @throws {Error} If signing fails.
*/
apkSigningMethods.signWithCustomCert = async function signWithCustomCert (apk) {
log.debug(`Signing '${apk}' with custom cert`);
if (!(await fs.exists(this.keystorePath))) {
throw new Error(`Keystore: ${this.keystorePath} doesn't exist.`);
}
if (!(await fs.exists(apk))) {
throw new Error(`'${apk}' doesn't exist.`);
}
try {
await this.executeApksigner(['sign',
'--ks', this.keystorePath,
'--ks-key-alias', this.keyAlias,
'--ks-pass', `pass:${this.keystorePassword}`,
'--key-pass', `pass:${this.keyPassword}`,
apk]);
} catch (err) {
log.warn(`Cannot use apksigner tool for signing. Defaulting to jarsigner. ` +
`Original error: ${err.stderr || err.message}`);
try {
if (await unsignApk(apk)) {
log.debug(`'${apk}' has been successfully unsigned`);
} else {
log.debug(`'${apk}' does not need to be unsigned`);
}
const jarsigner = path.resolve(getJavaHome(), 'bin',
`jarsigner${system.isWindows() ? '.exe' : ''}`);
const fullCmd = [jarsigner,
'-sigalg', 'MD5withRSA',
'-digestalg', 'SHA1',
'-keystore', this.keystorePath,
'-storepass', this.keystorePassword,
'-keypass', this.keyPassword,
apk, this.keyAlias];
log.debug(`Starting jarsigner: ${quote(fullCmd)}`);
await exec(fullCmd[0], fullCmd.slice(1));
} catch (e) {
throw new Error(`Could not sign with custom certificate. ` +
`Original error: ${e.stderr || e.message}`);
}
}
};
/**
* (Re)sign the given apk file on the local file system with either
* custom or default certificate based on _this.useKeystore_ property value
* and Zip-aligns it after signing.
*
* @param {string} appPath - The full path to the local .apk(s) file.
* @throws {Error} If signing fails.
*/
apkSigningMethods.sign = async function sign (appPath) {
if (appPath.endsWith(APKS_EXTENSION)) {
let message = 'Signing of .apks-files is not supported. ';
if (this.useKeystore) {
message += 'Consider manual application bundle signing with the custom keystore ' +
`like it is described at ${BUNDLETOOL_TUTORIAL}`;
} else {
message += `Consider manual application bundle signing with the key at '${DEFAULT_PRIVATE_KEY}' ` +
`and the certificate at '${DEFAULT_CERTIFICATE}'. Read ${BUNDLETOOL_TUTORIAL} for more details.`;
}
log.warn(message);
return;
}
let apksignerFound = true;
try {
await getApksignerForOs(this);
} catch (err) {
apksignerFound = false;
}
if (apksignerFound) {
// it is necessary to apply zipalign only before signing
// if apksigner is used or only after signing if we only have
// sign.jar utility
await this.zipAlignApk(appPath);
}
if (this.useKeystore) {
await this.signWithCustomCert(appPath);
} else {
await this.signWithDefaultCert(appPath);
}
if (!apksignerFound) {
await this.zipAlignApk(appPath);
}
};
/**
* Perform zip-aligning to the given local apk file.
*
* @param {string} apk - The full path to the local apk file.
* @returns {boolean} True if the apk has been successfully aligned
* or false if the apk has been already aligned.
* @throws {Error} If zip-align fails.
*/
apkSigningMethods.zipAlignApk = async function zipAlignApk (apk) {
await this.initZipAlign();
try {
await exec(this.binaries.zipalign, ['-c', '4', apk]);
log.debug(`${apk}' is already zip-aligned. Doing nothing`);
return false;
} catch (e) {
log.debug(`'${apk}' is not zip-aligned. Aligning`);
}
try {
await fs.access(apk, _fs.W_OK);
} catch (e) {
throw new Error(`The file at '${apk}' is not writeable. ` +
`Please grant write permissions to this file or to its parent folder '${path.dirname(apk)}' ` +
`for the Appium process, so it can zip-align the file`);
}
const alignedApk = await tempDir.path({prefix: 'appium', suffix: '.tmp'});
await mkdirp(path.dirname(alignedApk));
try {
await exec(this.binaries.zipalign, ['-f', '4', apk, alignedApk]);
await fs.mv(alignedApk, apk, { mkdirp: true });
return true;
} catch (e) {
if (await fs.exists(alignedApk)) {
await fs.unlink(alignedApk);
}
throw new Error(`zipAlignApk failed. Original error: ${e.stderr || e.message}`);
}
};
/**
* @typedef {Object} CertCheckOptions
* @property {boolean} requireDefaultCert [true] Whether to require that the destination APK
* is signed with the default Appium certificate or any valid certificate. This option
* only has effect if `useKeystore` property is unset.
*/
/**
* Check if the app is already signed with the default Appium certificate.
*
* @param {string} appPath - The full path to the local .apk(s) file.
* @param {string} pgk - The name of application package.
* @param {CertCheckOptions} opts - Certificate checking options
* @return {boolean} True if given application is already signed.
*/
apkSigningMethods.checkApkCert = async function checkApkCert (appPath, pkg, opts = {}) {
log.debug(`Checking app cert for ${appPath}`);
if (!await fs.exists(appPath)) {
log.debug(`'${appPath}' does not exist`);
return false;
}
if (this.useKeystore) {
return await this.checkCustomApkCert(appPath, pkg);
}
if (path.extname(appPath) === APKS_EXTENSION) {
appPath = await this.extractBaseApk(appPath);
}
const {
requireDefaultCert = true,
} = opts;
try {
await getApksignerForOs(this);
const output = await this.executeApksigner(['verify', '--print-certs', appPath]);
if (_.includes(output, DEFAULT_CERT_DIGEST)) {
log.debug(`'${appPath}' is signed with the default certificate`);
} else {
log.debug(`'${appPath}' is signed with a non-default certificate`);
}
return !requireDefaultCert || _.includes(output, DEFAULT_CERT_DIGEST);
} catch (err) {
// check if there is no signature
if (_.includes(err.stderr, APKSIGNER_VERIFY_FAIL)) {
log.debug(`'${appPath}' is not signed`);
return false;
}
log.warn(`Cannot use apksigner tool for signature verification. ` +
`Original error: ${err.message}`);
}
log.debug(`Defaulting to verify.jar`);
try {
await exec(getJavaForOs(), ['-jar', path.resolve(this.helperJarPath, 'verify.jar'), appPath]);
log.debug(`'${appPath}' is signed with the default certificate`);
return true;
} catch (err) {
if (!requireDefaultCert && _.includes(_.toLower(err.stderr), 'invalid cert')) {
log.debug(`'${appPath}' is signed with a non-default certificate`);
return true;
}
log.debug(`'${appPath}' is not signed with the default certificate`);
log.debug(err.stderr ? err.stderr : err.message);
return false;
}
};
/**
* Check if the app is already signed with a custom certificate.
*
* @param {string} appPath - The full path to the local apk(s) file.
* @param {string} pgk - The name of application package.
* @return {boolean} True if given application is already signed with a custom certificate.
*/
apkSigningMethods.checkCustomApkCert = async function checkCustomApkCert (appPath, pkg) {
log.debug(`Checking custom app cert for ${appPath}`);
if (path.extname(appPath) === APKS_EXTENSION) {
appPath = await this.extractBaseApk(appPath);
}
let h = 'a-fA-F0-9';
let md5Str = [`.*MD5.*((?:[${h}]{2}:){15}[${h}]{2})`];
let md5 = new RegExp(md5Str, 'mi');
let keytool = path.resolve(getJavaHome(), 'bin', `keytool${system.isWindows() ? '.exe' : ''}`);
let keystoreHash = await this.getKeystoreMd5(keytool, md5);
return await this.checkApkKeystoreMatch(keytool, md5, keystoreHash, pkg, appPath);
};
/**
* Get the MD5 hash of the keystore.
*
* @param {string} keytool - The name of the keytool utility.
* @param {RegExp} md5re - The pattern used to match the result in _keytool_ output.
* @return {?string} Keystore MD5 hash or _null_ if the hash cannot be parsed.
* @throws {Error} If getting keystore MD5 hash fails.
*/
apkSigningMethods.getKeystoreMd5 = async function getKeystoreMd5 (keytool, md5re) {
log.debug('Printing keystore md5.');
try {
let {stdout} = await exec(keytool, ['-v', '-list',
'-alias', this.keyAlias,
'-keystore', this.keystorePath,
'-storepass', this.keystorePassword]);
let keystoreHash = md5re.exec(stdout);
keystoreHash = keystoreHash ? keystoreHash[1] : null;
log.debug(`Keystore MD5: ${keystoreHash}`);
return keystoreHash;
} catch (e) {
throw new Error(`getKeystoreMd5 failed. Original error: ${e.message}`);
}
};
/**
* Check if the MD5 hash of the particular application matches to the given hash.
*
* @param {string} keytool - The name of the keytool utility.
* @param {RegExp} md5re - The pattern used to match the result in _keytool_ output.
* @param {string} keystoreHash - The expected hash value.
* @param {string} pkg - The name of the installed package.
* @param {string} apk - The full path to the existing apk file.
* @return {boolean} True if both hashes are equal.
* @throws {Error} If getting keystore MD5 hash fails.
*/
apkSigningMethods.checkApkKeystoreMatch = async function checkApkKeystoreMatch (keytool, md5re, keystoreHash, pkg, apk) {
let entryHash = null;
let rsa = /^META-INF\/.*\.[rR][sS][aA]$/;
let foundKeystoreMatch = false;
//for (let entry of entries) {
await zip.readEntries(apk, async ({entry, extractEntryTo}) => {
entry = entry.fileName;
if (!rsa.test(entry)) {
return;
}
log.debug(`Entry: ${entry}`);
let entryPath = path.join(this.tmpDir, pkg, 'cert');
log.debug(`entryPath: ${entryPath}`);
let entryFile = path.join(entryPath, entry);
log.debug(`entryFile: ${entryFile}`);
// ensure /tmp/pkg/cert/ doesn't exist or extract will fail.
await fs.rimraf(entryPath);
// META-INF/CERT.RSA
await extractEntryTo(entryPath);
log.debug('extracted!');
// check for match
log.debug('Printing apk md5.');
let {stdout} = await exec(keytool, ['-v', '-printcert', '-file', entryFile]);
entryHash = md5re.exec(stdout);
entryHash = entryHash ? entryHash[1] : null;
log.debug(`entryHash MD5: ${entryHash}`);
log.debug(`keystore MD5: ${keystoreHash}`);
let matchesKeystore = entryHash && entryHash === keystoreHash;
log.debug(`Matches keystore? ${matchesKeystore}`);
// If we have a keystore match, stop iterating
if (matchesKeystore) {
foundKeystoreMatch = true;
return false;
}
});
return foundKeystoreMatch;
};
export default apkSigningMethods;