appium-adb
Version:
Android Debug Bridge interface
425 lines • 16.7 kB
JavaScript
;
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