UNPKG

appium-adb

Version:

Android Debug Bridge interface

425 lines 16.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.executeApksigner = executeApksigner; exports.signWithDefaultCert = signWithDefaultCert; exports.signWithCustomCert = signWithCustomCert; exports.sign = sign; exports.zipAlignApk = zipAlignApk; exports.checkApkCert = checkApkCert; exports.getKeystoreHash = getKeystoreHash; exports.getApksignerForOs = getApksignerForOs; exports.unsignApk = unsignApk; const lodash_1 = __importDefault(require("lodash")); const node_fs_1 = __importDefault(require("node:fs")); const teen_process_1 = require("teen_process"); const node_path_1 = __importDefault(require("node:path")); const logger_1 = require("../logger"); const support_1 = require("@appium/support"); const lru_cache_1 = require("lru-cache"); const helpers_1 = require("../helpers"); const DEFAULT_PRIVATE_KEY = node_path_1.default.join('keys', 'testkey.pk8'); const DEFAULT_CERTIFICATE = node_path_1.default.join('keys', 'testkey.x509.pem'); const BUNDLETOOL_TUTORIAL = 'https://developer.android.com/studio/command-line/bundletool'; const APKSIGNER_VERIFY_FAIL = 'DOES NOT VERIFY'; const SHA1 = 'sha1'; const SHA256 = 'sha256'; const SHA512 = 'sha512'; const MD5 = 'md5'; const DEFAULT_CERT_HASH = { [SHA256]: 'a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc', }; const JAVA_PROPS_INIT_ERROR = 'java.lang.Error: Properties init'; const SIGNED_APPS_CACHE = new lru_cache_1.LRUCache({ max: 30, }); /** * Execute apksigner utility with given arguments. * * @param args - The list of tool arguments. * @returns - Command stdout * @throws If apksigner binary is not present on the local file system * or the return code is not equal to zero. */ async function executeApksigner(args) { const apkSignerJar = await getApksignerForOs.bind(this)(); const fullCmd = [await (0, helpers_1.getJavaForOs)(), '-Xmx1024M', '-Xss1m', '-jar', apkSignerJar, ...args]; logger_1.log.debug(`Starting apksigner: ${support_1.util.quote(fullCmd)}`); // It is necessary to specify CWD explicitly; see https://github.com/appium/appium/issues/14724#issuecomment-737446715 const { stdout, stderr } = await (0, teen_process_1.exec)(fullCmd[0], fullCmd.slice(1), { cwd: node_path_1.default.dirname(apkSignerJar), }); for (const [name, stream] of [ ['stdout', stdout], ['stderr', stderr], ]) { if (!lodash_1.default.trim(stream)) { continue; } if (name === 'stdout') { // Make the output less talkative const filteredStream = stream .split('\n') .filter((line) => !line.includes('WARNING:')) .join('\n'); logger_1.log.debug(`apksigner ${name}: ${filteredStream}`); } else { logger_1.log.debug(`apksigner ${name}: ${stream}`); } } return stdout; } /** * (Re)sign the given apk file on the local file system with the default certificate. * * @param apk - The full path to the local apk file. * @throws If signing fails. */ async function signWithDefaultCert(apk) { logger_1.log.debug(`Signing '${apk}' with default cert`); if (!(await support_1.fs.exists(apk))) { throw new Error(`${apk} file doesn't exist.`); } const args = [ 'sign', '--key', await (0, helpers_1.getResourcePath)(DEFAULT_PRIVATE_KEY), '--cert', await (0, helpers_1.getResourcePath)(DEFAULT_CERTIFICATE), apk, ]; try { await this.executeApksigner(args); } catch (e) { const err = e; throw new Error(`Could not sign '${apk}' with the default certificate. ` + `Original error: ${err.stderr || err.stdout || err.message}`); } } /** * (Re)sign the given apk file on the local file system with a custom certificate. * * @param apk - The full path to the local apk file. * @throws If signing fails. */ async function signWithCustomCert(apk) { logger_1.log.debug(`Signing '${apk}' with custom cert`); if (!(await support_1.fs.exists(this.keystorePath))) { throw new Error(`Keystore: ${this.keystorePath} doesn't exist.`); } if (!(await support_1.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) { const error = err; logger_1.log.warn(`Cannot use apksigner tool for signing. Defaulting to jarsigner. ` + `Original error: ${error.stderr || error.stdout || error.message}`); try { if (await unsignApk(apk)) { logger_1.log.debug(`'${apk}' has been successfully unsigned`); } else { logger_1.log.debug(`'${apk}' does not need to be unsigned`); } const jarsigner = node_path_1.default.resolve(await (0, helpers_1.getJavaHome)(), 'bin', `jarsigner${support_1.system.isWindows() ? '.exe' : ''}`); const fullCmd = [ jarsigner, '-sigalg', 'MD5withRSA', '-digestalg', 'SHA1', '-keystore', this.keystorePath, '-storepass', this.keystorePassword, '-keypass', this.keyPassword, apk, this.keyAlias, ]; logger_1.log.debug(`Starting jarsigner: ${support_1.util.quote(fullCmd)}`); await (0, teen_process_1.exec)(fullCmd[0], fullCmd.slice(1)); } catch (e) { const execErr = e; throw new Error(`Could not sign with custom certificate. ` + `Original error: ${execErr.stderr || execErr.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 appPath - The full path to the local .apk(s) file. * @throws If signing fails. */ async function sign(appPath) { if (appPath.endsWith(helpers_1.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.`; } logger_1.log.warn(message); return; } // it is necessary to apply zipalign only before signing // if apksigner is used await this.zipAlignApk(appPath); if (this.useKeystore) { await this.signWithCustomCert(appPath); } else { await this.signWithDefaultCert(appPath); } } /** * Perform zip-aligning to the given local apk file. * * @param apk - The full path to the local apk file. * @returns True if the apk has been successfully aligned * or false if the apk has been already aligned. * @throws If zip-align fails. */ async function zipAlignApk(apk) { await this.initZipAlign(); try { await (0, teen_process_1.exec)(this.binaries.zipalign, ['-c', '4', apk]); logger_1.log.debug(`${apk}' is already zip-aligned. Doing nothing`); return false; } catch { logger_1.log.debug(`'${apk}' is not zip-aligned. Aligning`); } try { await support_1.fs.access(apk, node_fs_1.default.constants.W_OK); } catch { throw new Error(`The file at '${apk}' is not writeable. ` + `Please grant write permissions to this file or to its parent folder '${node_path_1.default.dirname(apk)}' ` + `for the Appium process, so it can zip-align the file`); } const alignedApk = await support_1.tempDir.path({ prefix: 'appium', suffix: '.tmp' }); await (0, support_1.mkdirp)(node_path_1.default.dirname(alignedApk)); try { await (0, teen_process_1.exec)(this.binaries.zipalign, ['-f', '4', apk, alignedApk]); await support_1.fs.mv(alignedApk, apk, { mkdirp: true }); return true; } catch (e) { const err = e; if (await support_1.fs.exists(alignedApk)) { await support_1.fs.unlink(alignedApk); } throw new Error(`zipAlignApk failed. Original error: ${err.message || err.stderr}`); } } /** * Check if the app is already signed with the default Appium certificate. * * @param appPath - The full path to the local .apk(s) file. * @param pkg - The name of application package. * @param opts - Certificate checking options * @returns True if given application is already signed. */ async function checkApkCert(appPath, pkg, opts = {}) { logger_1.log.debug(`Checking app cert for ${appPath}`); if (!(await support_1.fs.exists(appPath))) { logger_1.log.debug(`'${appPath}' does not exist`); return false; } let actualAppPath = appPath; if (node_path_1.default.extname(appPath) === helpers_1.APKS_EXTENSION) { actualAppPath = await this.extractBaseApk(appPath); } const hashMatches = (apksignerOutput, expectedHashes) => { for (const [name, value] of lodash_1.default.toPairs(expectedHashes)) { if (value && new RegExp(`digest:\\s+${value}\\b`, 'i').test(apksignerOutput)) { logger_1.log.debug(`${name} hash did match for '${node_path_1.default.basename(actualAppPath)}'`); return true; } } return false; }; const { requireDefaultCert = true } = opts; const appHash = await support_1.fs.hash(actualAppPath); if (SIGNED_APPS_CACHE.has(appHash)) { logger_1.log.debug(`Using the previously cached signature entry for '${node_path_1.default.basename(actualAppPath)}'`); const cached = SIGNED_APPS_CACHE.get(appHash); if (cached) { const { keystorePath, output, expected } = cached; if ((this.useKeystore && this.keystorePath === keystorePath) || !this.useKeystore) { return (!this.useKeystore && !requireDefaultCert) || hashMatches(output, expected); } } } const expected = this.useKeystore ? await this.getKeystoreHash() : DEFAULT_CERT_HASH; try { await getApksignerForOs.bind(this)(); const output = await this.executeApksigner(['verify', '--print-certs', actualAppPath]); const hasMatch = hashMatches(output, expected); if (hasMatch) { logger_1.log.info(`'${actualAppPath}' is signed with the ` + `${this.useKeystore ? 'keystore' : 'default'} certificate`); } else { logger_1.log.info(`'${actualAppPath}' is signed with a ` + `non-${this.useKeystore ? 'keystore' : 'default'} certificate`); } const isSigned = (!this.useKeystore && !requireDefaultCert) || hasMatch; if (isSigned) { SIGNED_APPS_CACHE.set(appHash, { output, expected, keystorePath: this.keystorePath, }); } return isSigned; } catch (err) { const error = err; // check if there is no signature if (lodash_1.default.includes(error.stderr, APKSIGNER_VERIFY_FAIL)) { logger_1.log.info(`'${actualAppPath}' is not signed`); return false; } const errMsg = error.stderr || error.stdout || error.message; if (lodash_1.default.includes(errMsg, JAVA_PROPS_INIT_ERROR)) { // This error pops up randomly and we are not quite sure why. // My guess - a race condition in java vm initialization. // Nevertheless, lets make Appium to believe the file is already signed, // because it would be true for 99% of UIAutomator2-based // tests, where we presign server binaries while publishing their NPM module. // If these are not signed, e.g. in case of Espresso, then the next step(s) // would anyway fail. // See https://github.com/appium/appium/issues/14724 for more details. logger_1.log.warn(errMsg); logger_1.log.warn(`Assuming '${actualAppPath}' is already signed and continuing anyway`); return true; } throw new Error(`Cannot verify the signature of '${actualAppPath}'. ` + `Original error: ${errMsg}`); } } /** * Retrieve the the hash of the given keystore. * * @returns * @throws If getting keystore hash fails. */ async function getKeystoreHash() { logger_1.log.debug(`Getting hash of the '${this.keystorePath}' keystore`); const keytool = node_path_1.default.resolve(await (0, helpers_1.getJavaHome)(), 'bin', `keytool${support_1.system.isWindows() ? '.exe' : ''}`); if (!(await support_1.fs.exists(keytool))) { throw new Error(`The keytool utility cannot be found at '${keytool}'`); } const args = [ '-v', '-list', '-alias', this.keyAlias, '-keystore', this.keystorePath, '-storepass', this.keystorePassword, ]; logger_1.log.info(`Running '${keytool}' with arguments: ${support_1.util.quote(args)}`); try { const { stdout } = await (0, teen_process_1.exec)(keytool, args); const result = {}; for (const hashName of [SHA512, SHA256, SHA1, MD5]) { const hashRe = new RegExp(`^\\s*${hashName}:\\s*([a-f0-9:]+)`, 'mi'); const match = hashRe.exec(stdout); if (!match) { continue; } result[hashName] = match[1].replace(/:/g, '').toLowerCase(); } if (lodash_1.default.isEmpty(result)) { logger_1.log.debug(stdout); throw new Error('Cannot parse the hash value from the keytool output'); } logger_1.log.debug(`Keystore hash: ${JSON.stringify(result)}`); return result; } catch (e) { const err = e; throw new Error(`Cannot get the hash of '${this.keystorePath}' keystore. ` + `Original error: ${err.stderr || err.message}`); } } // #region Private functions /** * Get the absolute path to apksigner tool * * @returns An absolute path to apksigner tool. * @throws If the tool is not present on the local file system. */ async function getApksignerForOs() { return await this.getBinaryFromSdkRoot('apksigner.jar'); } /** * Unsigns the given apk by removing the * META-INF folder recursively from the archive. * !!! The function overwrites the given apk after successful unsigning !!! * * @param apkPath The path to the apk * @returns `true` if the apk has been successfully * unsigned and overwritten * @throws if there was an error during the unsign operation */ async function unsignApk(apkPath) { const tmpRoot = await support_1.tempDir.openDir(); const metaInfFolderName = 'META-INF'; try { let hasMetaInf = false; await support_1.zip.readEntries(apkPath, ({ entry }) => { hasMetaInf = entry.fileName.startsWith(`${metaInfFolderName}/`); // entries iteration stops after `false` is returned return !hasMetaInf; }); if (!hasMetaInf) { return false; } const tmpZipRoot = node_path_1.default.resolve(tmpRoot, 'apk'); await support_1.zip.extractAllTo(apkPath, tmpZipRoot); await support_1.fs.rimraf(node_path_1.default.resolve(tmpZipRoot, metaInfFolderName)); const tmpResultPath = node_path_1.default.resolve(tmpRoot, node_path_1.default.basename(apkPath)); await support_1.zip.toArchive(tmpResultPath, { cwd: tmpZipRoot, }); await support_1.fs.unlink(apkPath); await support_1.fs.mv(tmpResultPath, apkPath); return true; } finally { await support_1.fs.rimraf(tmpRoot); } } // #endregion //# sourceMappingURL=apk-signing.js.map