diff --git a/docs/README.md b/docs/README.md index 5d32b47..8d26da0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,7 +42,7 @@ An ID of a known permission on Android. #### Defined in -[android.ts:913](https://github.com/tweaselORG/appstraction/blob/main/src/android.ts#L913) +[android.ts:955](https://github.com/tweaselORG/appstraction/blob/main/src/android.ts#L955) ___ @@ -81,7 +81,7 @@ A supported attribute for the `getDeviceAttribute()` function, depending on the #### Defined in -[index.ts:400](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L400) +[index.ts:439](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L439) ___ @@ -100,7 +100,7 @@ The options for each attribute available through the `getDeviceAttribute()` func #### Defined in -[index.ts:406](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L406) +[index.ts:445](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L445) ___ @@ -112,7 +112,7 @@ An ID of a known permission on iOS. #### Defined in -[ios.ts:455](https://github.com/tweaselORG/appstraction/blob/main/src/ios.ts#L455) +[ios.ts:627](https://github.com/tweaselORG/appstraction/blob/main/src/ios.ts#L627) ___ @@ -176,6 +176,7 @@ Functions that are available for the platforms. | `target.platform` | `Platform` | The platform this instance is configured for, i.e. `ios` or `android`. | | `target.runTarget` | `RunTarget` | The run target this instance is configured for, i.e. `device` or `emulator`. | | `uninstallApp` | (`appId`: `string`) => `Promise`<`void`\> | Uninstall the app with the given app ID. Will not fail if the app is not installed. This also removes any data stored by the app. | +| `unlockScreen` | () => `Promise`<`void`\> | Simulates key presses to unlock the screen. This only works if no passcode is set on the device. | | `waitForDevice` | (`tries?`: `number`) => `Promise`<`void`\> | Wait until the device or emulator has been connected and has booted up completely. | #### Defined in @@ -200,7 +201,7 @@ The options for the `platformApi()` function. #### Defined in -[index.ts:338](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L338) +[index.ts:368](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L368) ___ @@ -219,7 +220,7 @@ Connection details for a proxy. #### Defined in -[index.ts:414](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L414) +[index.ts:453](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L453) ___ @@ -243,19 +244,19 @@ The options for a specific platform/run target combination. | `android` | { `device`: `unknown` ; `emulator`: `unknown` } | The options for the Android platform. | | `android.device` | `unknown` | The options for the Android physical device run target. | | `android.emulator` | `unknown` | The options for the Android emulator run target. | -| `ios` | { `device`: ``"ssh"`` extends `Capability` ? { `ip`: `string` ; `rootPw?`: `string` } : `unknown` ; `emulator`: `never` } | The options for the iOS platform. | -| `ios.device` | ``"ssh"`` extends `Capability` ? { `ip`: `string` ; `rootPw?`: `string` } : `unknown` | The options for the iOS physical device run target. | +| `ios` | { `device`: ``"ssh"`` extends `Capability` ? { `ip`: `string` ; `rootPw?`: `string` } : `unknown` & ``"supervision"`` extends `Capability` ? { `supervisionKeyPassword?`: `string` } : `unknown` ; `emulator`: `never` } | The options for the iOS platform. | +| `ios.device` | ``"ssh"`` extends `Capability` ? { `ip`: `string` ; `rootPw?`: `string` } : `unknown` & ``"supervision"`` extends `Capability` ? { `supervisionKeyPassword?`: `string` } : `unknown` | The options for the iOS physical device run target. | | `ios.emulator` | `never` | The options for the iOS emulator run target. | #### Defined in -[index.ts:365](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L365) +[index.ts:395](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L395) ___ ### SupportedCapability -Ƭ **SupportedCapability**<`Platform`\>: `Platform` extends ``"android"`` ? ``"wireguard"`` \| ``"root"`` \| ``"frida"`` \| ``"certificate-pinning-bypass"`` : `Platform` extends ``"ios"`` ? ``"ssh"`` \| ``"frida"`` \| ``"certificate-pinning-bypass"`` : `never` +Ƭ **SupportedCapability**<`Platform`\>: `Platform` extends ``"android"`` ? ``"wireguard"`` \| ``"root"`` \| ``"frida"`` \| ``"certificate-pinning-bypass"`` : `Platform` extends ``"ios"`` ? ``"ssh"`` \| ``"frida"`` \| ``"certificate-pinning-bypass"`` \| ``"supervision"`` : `never` A capability for the `platformApi()` function. @@ -267,7 +268,7 @@ A capability for the `platformApi()` function. #### Defined in -[index.ts:393](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L393) +[index.ts:432](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L432) ___ @@ -309,7 +310,7 @@ Configuration string for WireGuard. #### Defined in -[index.ts:421](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L421) +[index.ts:460](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L460) ## Variables @@ -321,7 +322,7 @@ The IDs of known permissions on Android. #### Defined in -[android.ts:782](https://github.com/tweaselORG/appstraction/blob/main/src/android.ts#L782) +[android.ts:824](https://github.com/tweaselORG/appstraction/blob/main/src/android.ts#L824) ___ @@ -333,7 +334,7 @@ The IDs of known permissions on iOS. #### Defined in -[ios.ts:438](https://github.com/tweaselORG/appstraction/blob/main/src/ios.ts#L438) +[ios.ts:610](https://github.com/tweaselORG/appstraction/blob/main/src/ios.ts#L610) ## Functions @@ -372,7 +373,7 @@ An object with the properties listed above, or `undefined` if the file doesn't e #### Defined in -[util.ts:68](https://github.com/tweaselORG/appstraction/blob/main/src/util.ts#L68) +[utils/index.ts:63](https://github.com/tweaselORG/appstraction/blob/main/src/utils/index.ts#L63) ___ @@ -394,7 +395,7 @@ Pause for a given duration. #### Defined in -[util.ts:45](https://github.com/tweaselORG/appstraction/blob/main/src/util.ts#L45) +[utils/index.ts:40](https://github.com/tweaselORG/appstraction/blob/main/src/utils/index.ts#L40) ___ @@ -426,4 +427,4 @@ The API object for the given platform and run target. #### Defined in -[index.ts:430](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L430) +[index.ts:469](https://github.com/tweaselORG/appstraction/blob/main/src/index.ts#L469) diff --git a/package.json b/package.json index c84eba8..fb1c431 100644 --- a/package.json +++ b/package.json @@ -56,16 +56,19 @@ "@napi-rs/lzma": "^1.1.2", "andromatic": "^1.0.0", "autopy": "^1.1.1", + "bplist-creator": "^0.1.1", "cross-fetch": "^3.1.5", "execa": "^6.1.0", "file-type": "^18.3.0", "frida": "^16.0.8", "fs-extra": "^11.1.0", + "global-cache-dir": "^5.0.0", "ipa-extract-info": "^1.2.6", + "node-forge": "^1.3.1", "node-ssh": "^13.1.0", "p-retry": "^5.1.2", - "pkijs": "^3.0.14", "semver": "^7.3.8", + "simple-plist": "^1.3.1", "tempy": "^3.0.0", "ts-node": "^10.9.1", "yauzl": "^2.10.0" @@ -78,6 +81,7 @@ "@parcel/transformer-typescript-types": "2.8.2", "@types/fs-extra": "^11.0.0", "@types/node": "^18.11.18", + "@types/node-forge": "^1.3.2", "@types/plist": "^3.0.2", "@types/promise-timeout": "^1.3.0", "@types/semver": "^7.3.13", @@ -94,5 +98,8 @@ "typedoc": "^0.23.26", "typedoc-plugin-markdown": "3.14.0", "typescript": "4.9.4" + }, + "engines": { + "node": ">=15.0.0" } } diff --git a/src/android.ts b/src/android.ts index 8b31d27..00a1f1d 100644 --- a/src/android.ts +++ b/src/android.ts @@ -6,6 +6,7 @@ import { createHash, randomUUID } from 'crypto'; import { fileTypeFromFile } from 'file-type'; import frida from 'frida'; import { open, rm, writeFile } from 'fs/promises'; +import forge from 'node-forge'; import pRetry from 'p-retry'; import { basename, dirname } from 'path'; import { major as semverMajor, minVersion as semverMinVersion } from 'semver'; @@ -20,20 +21,18 @@ import type { } from '.'; import { dependencies } from '../package.json'; import { venvOptions } from '../scripts/common/python'; -import type { ParametersExceptFirst, XapkManifest } from './util'; +import type { ParametersExceptFirst, XapkManifest } from './utils'; import { asyncUnimplemented, escapeArg, escapeCommand, - forEachInZip, - getFileFromZip, getObjFromFridaScript, isRecord, parseAppMeta, - parsePemCertificateFromFile, retryCondition, - tmpFileFromZipEntry, -} from './util'; +} from './utils'; +import { certSubjectToAsn1, parsePemCertificateFromFile } from './utils/crypto'; +import { forEachInZip, getFileFromZip, tmpFileFromZipEntry } from './utils/zip'; const adb = (...args: ParametersExceptFirst) => runAndroidDevTool('adb', args[0], args[1]); const venv = getVenv(venvOptions); @@ -231,7 +230,9 @@ export const androidApi = >( getCertificateSubjectHashOld: async (path: string) => { const { cert } = await parsePemCertificateFromFile(path); - const hash = createHash('md5').update(Buffer.from(cert.subject.valueBeforeDecode)).digest(); + const hash = createHash('md5') + .update(forge.asn1.toDer(certSubjectToAsn1(cert)).toHex(), 'hex') + .digest(); const truncated = hash.subarray(0, 4); const ulong = (truncated[0]! | (truncated[1]! << 8) | (truncated[2]! << 16) | (truncated[3]! << 24)) >>> 0; @@ -808,6 +809,15 @@ export const androidApi = >( ) throw new Error('Failed to set proxy.'); }, + unlockScreen: async () => { + const { stdout } = await adb(['shell', 'dumpsys', 'window']); + if (stdout.includes('mAwake=false')) + // The screen is off, simulate lock button press + await adb(['shell', 'input', 'keyevent', '26']); + if (stdout.includes('mShowingLockscreen=true') || stdout.includes('mDreamingLockscreen=true')) + // The screen is locked, simulate a menu key press + await adb(['shell', 'input', 'keyevent', '82']); + }, }); /** The IDs of known permissions on Android. */ diff --git a/src/index.ts b/src/index.ts index 93ce20b..9a719b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import type { AndroidPermission } from './android'; import { androidApi } from './android'; import type { IosPermission } from './ios'; import { iosApi } from './ios'; -import type { ParametersExceptFirst } from './util'; +import type { ParametersExceptFirst } from './utils'; /** A platform that is supported by this library. */ export type SupportedPlatform = 'android' | 'ios'; @@ -285,6 +285,9 @@ export type PlatformApi< : Platform extends 'ios' ? (proxy: Proxy | null) => Promise : never; + + /** Simulates key presses to unlock the screen. This only works if no passcode is set on the device. */ + unlockScreen: () => Promise; /** * An indicator for what platform and run target this instance of PlatformApi is configured for. This is useful * mostly to write typeguards. @@ -326,6 +329,29 @@ export type PlatformApi< */ setupEnvironment: () => Promise; ensureFrida: () => Promise; + /** + * Ensures that the current host is configured to supervise the connected device. If this is not the case, + * it sets the host to be the (only) supervisor by installing its certificate on the device. This will + * overwrite parts of the exisiting CloudConfiguration. If there is no host certificate yet, or it has + * expired, it will be generated. + * + * Might restart the device, if a new configuration is pushed. You are advised to wait for the device. + * + * @param forceNewKey If set to `true`, a new host key will be generated and set up, even if there is + * already a valid old one. + */ + ensureSupervision: (options?: { forceNewKey?: boolean }) => Promise; + /** + * Removes all configured supervision hosts from the device. + * + * Will restart the device. You are advised to wait for the device. + */ + removeSupervision: () => Promise; + /** + * Restarts the device only in userspace, e.g. to keep the jailbroken kernel running. You might want to + * wait for the device to ensure it is available again. + */ + userspaceRestart: () => Promise; } : never; }; @@ -382,14 +408,23 @@ export type RunTargetOptions< /** The options for the iOS emulator run target. */ emulator: never; /** The options for the iOS physical device run target. */ - device: 'ssh' extends Capability + device: ('ssh' extends Capability ? { /** The password of the root user on the device, defaults to `alpine` if not set. */ rootPw?: string; /** The device's IP address. */ ip: string; } - : unknown; + : unknown) & + ('supervision' extends Capability + ? { + /** + * The password of the private key of the supervision certificate, defaults to `appstraction` if + * not set. + */ + supervisionKeyPassword?: string; + } + : unknown); }; }; @@ -397,7 +432,7 @@ export type RunTargetOptions< export type SupportedCapability = Platform extends 'android' ? 'wireguard' | 'root' | 'frida' | 'certificate-pinning-bypass' : Platform extends 'ios' - ? 'ssh' | 'frida' | 'certificate-pinning-bypass' + ? 'ssh' | 'frida' | 'certificate-pinning-bypass' | 'supervision' : never; /** A supported attribute for the `getDeviceAttribute()` function, depending on the platform. */ @@ -450,5 +485,5 @@ export function platformApi< export { androidPermissions } from './android'; export { iosPermissions } from './ios'; -export { parseAppMeta, pause } from './util'; +export { parseAppMeta, pause } from './utils'; export { IosPermission, AndroidPermission }; diff --git a/src/ios.ts b/src/ios.ts index f102d6e..1b1dc94 100644 --- a/src/ios.ts +++ b/src/ios.ts @@ -1,16 +1,27 @@ import { getVenv } from 'autopy'; +import bplist from 'bplist-creator'; import { createHash } from 'crypto'; import frida from 'frida'; +import { exists, mkdirp } from 'fs-extra'; +import { readFile, writeFile } from 'fs/promises'; +import globalCacheDir from 'global-cache-dir'; import { NodeSSH } from 'node-ssh'; +import { join } from 'path'; +import simplePlist from 'simple-plist'; import type { PlatformApi, PlatformApiOptions, Proxy, SupportedCapability, SupportedRunTarget } from '.'; import { venvOptions } from '../scripts/common/python'; +import { asyncUnimplemented, getObjFromFridaScript, isRecord, retryCondition } from './utils'; import { - asyncUnimplemented, - getObjFromFridaScript, - isRecord, + arrayBufferToPem, + asn1ValueToDer, + certificateFingerprint, + certificateHasExpired, + certSubjectToAsn1, + createPkcs12Container, + generateCertificate, parsePemCertificateFromFile, - retryCondition, -} from './util'; + pemToArrayBuffer, +} from './utils/crypto'; const venv = getVenv(venvOptions); const python = async (...args: Parameters>) => (await venv)(...args); @@ -124,7 +135,16 @@ function getProxySettingsForCurrentWifiNetwork() { } send({ name: "get_obj_from_frida_script", payload: getProxySettingsForCurrentWifiNetwork() });`, + simulateHomeButton: `// See https://github.com/tweaselORG/appstraction/issues/107 +var atServer = ObjC.classes.HNDAssistiveTouchServer.sharedInstance(); +// frida somehow needs this to attach the method to the object (https://github.com/tweaselORG/appstraction/issues/107#issuecomment-1608013662) +Object.getOwnPropertyNames(atServer) +atServer._home() +// The process will always crash after this, but the home button press will be simulated before that. +`, } as const; +const cloudConfigPath = + '/var/containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles/CloudConfigurationDetails.plist' as const; export const iosApi = >( options: PlatformApiOptions<'ios', RunTarget, SupportedCapability<'ios'>[]> @@ -141,7 +161,7 @@ export const iosApi = >( // Creating and disposing a new SSH connection for each command is not efficient but it replicates the // previous behaviour of calling `ssh`. If we wanted to keep the connection open, we would also need a way // to dispose of it at the very end, but we don't know when that is (cf. #24). - ssh.dispose(); + if (ssh.connection) ssh.dispose(); return res; }, async setupEnvironment() { @@ -210,6 +230,105 @@ Components:" > /etc/apt/sources.list.d/appstraction.sources`); }); await session.detach(); }, + async ensureSupervision(supervisionOptions) { + if (!options.capabilities.includes('ssh')) + throw new Error('SSH is currently required to ensure supervision mode.'); + + const orgName = 'appstraction'; + const cacheDir = await globalCacheDir('appstraction'); + + const { stdout: encodedPlist } = await this.ssh(`cat ${cloudConfigPath} | base64`); + const plist = (await simplePlist.parse(Buffer.from(encodedPlist, 'base64'))) as + | CloudConfigurationDetails + | undefined; + + if (!plist) throw new Error('Failed to ensure supervision mode: Invalid CloudConfiguration.'); + + let hostCert; + + if ( + !supervisionOptions?.forceNewKey && + (await exists(join(cacheDir, 'ios', 'supervisorCert.pem'))) && + (await exists(join(cacheDir, 'ios', 'supervisorKeyStore.p12'))) + ) { + hostCert = (await readFile(join(cacheDir, 'ios', 'supervisorCert.pem'))).toString(); + + if (!certificateHasExpired(hostCert)) { + const hostCertFingerprint = certificateFingerprint(hostCert); + + try { + // Test if the current host certificate is already controlling the device. + if ( + plist.IsSupervised && + plist.SupervisorHostCertificates && + plist.SupervisorHostCertificates.length > 0 && + plist.SupervisorHostCertificates.some( + (cert) => + certificateFingerprint(arrayBufferToPem(cert, 'CERTIFICATE')) === + hostCertFingerprint + ) + ) + return; + } catch (e) { + // The certificate is invalid, so we need to generate a new one. + hostCert = undefined; + } + } else { + hostCert = undefined; + } + } + + if (!hostCert || supervisionOptions?.forceNewKey) { + // We have no existing keys, so let’s generate one. + const generated = await generateCertificate(orgName); + hostCert = generated.certificate; + const hostKey = generated.privateKey; + + const keyStore = createPkcs12Container( + hostCert, + hostKey, + options.targetOptions?.supervisionKeyPassword || 'appstraction' + ); + + await mkdirp(join(cacheDir, 'ios')); + await writeFile(join(cacheDir, 'ios', 'supervisorCert.pem'), hostCert); + await writeFile(join(cacheDir, 'ios', 'supervisorKeyStore.p12'), Buffer.from(keyStore.toHex(), 'hex')); + } + + const newPlist = { + ...plist, + SupervisorHostCertificates: [Buffer.from(pemToArrayBuffer(hostCert))], + IsSupervised: true, + OrganizationName: orgName, + AllowPairing: true, + }; + + await this.ssh(`echo "${bplist(newPlist).toString('base64')}" | base64 -d > ${cloudConfigPath}`); + await this.userspaceRestart(); + }, + async removeSupervision() { + const { stdout: encodedPlist } = await this.ssh(`cat ${cloudConfigPath} | base64`); + const plist = (await simplePlist.parse(Buffer.from(encodedPlist, 'base64'))) as + | CloudConfigurationDetails + | undefined; + + if (!plist) throw new Error('Failed to remove supervision mode: Invalid CloudConfiguration.'); + const newPlist = { + ...plist, + SupervisorHostCertificates: [], + IsSupervised: false, + OrganizationName: '', + }; + + await this.ssh(`echo "${bplist(newPlist).toString('base64')}" | base64 -d > ${cloudConfigPath}`); + await this.userspaceRestart(); + }, + async userspaceRestart() { + if (!options.capabilities.includes('ssh')) + throw new Error('SSH is currently required to restart in userspace.'); + + await this.ssh('launchctl reboot userspace'); + }, }, resetDevice: asyncUnimplemented('resetDevice') as never, @@ -250,6 +369,17 @@ Components:" > /etc/apt/sources.list.d/appstraction.sources`); if (options.capabilities.includes('frida')) { await this._internal.ensureFrida(); } + + if (options.capabilities.includes('supervision')) { + if (!options.capabilities.includes('ssh')) + throw new Error( + 'Unimplemented on this platform: Activating supervision mode without the ssh capability.' + ); + + await this._internal.ensureSupervision(); + await this.waitForDevice(); + await this.unlockScreen(); + } }, clearStuckModals: asyncUnimplemented('clearStuckModals') as never, @@ -385,7 +515,7 @@ Components:" > /etc/apt/sources.list.d/appstraction.sources`); const { cert, certDer } = await parsePemCertificateFromFile(path); const sha256 = createHash('sha256').update(certDer).digest('hex'); - const subj = Buffer.from(cert.subject.toSchema().valueBlock.toBER()).toString('hex'); + const subj = asn1ValueToDer(certSubjectToAsn1(cert)).toHex(); const tset = Buffer.from( ` @@ -404,6 +534,7 @@ Components:" > /etc/apt/sources.list.d/appstraction.sources`); throw new Error('SSH is required for removing a certificate authority.'); const { certDer } = await parsePemCertificateFromFile(path); + const sha256 = createHash('sha256').update(certDer).digest('hex'); await this._internal.ssh( @@ -432,8 +563,49 @@ Components:" > /etc/apt/sources.list.d/appstraction.sources`); ) throw new Error('Failed to set proxy.'); }, + unlockScreen: async () => { + if (!options.capabilities.includes('frida')) throw new Error('Frida is required for unlocking the screen.'); + await frida + .getUsbDevice() + .then((f) => f.attach('assistivetouchd')) + .then(async (s) => { + await (await s.createScript(fridaScripts.simulateHomeButton)).load(); + await s.detach(); + }) + .catch((e) => { + if (e.message === 'Process not found') + throw new Error( + 'AssistiveTouch service is not running. Enable it in Settings > Accessibility > Touch > AssistiveTouch.' + ); + // TODO: Enable AssistiveTouch automatically. This can be done via lockdownd, but is not supported by pymobiledevice3, yet. + }); + // Since assistivetouchd always crashes after the simulated home button press, we need to wait for it to restart. + await retryCondition( + () => + python('pymobiledevice3', ['processes', 'ps', '--no-color']).then(({ stdout }) => + Object.values(JSON.parse(stdout) as Record>).some( + (p) => p['ProcessName'] === 'assistivetouchd' + ) + ), + 5 + ); + await frida + .getUsbDevice() + .then((f) => f.attach('assistivetouchd')) + .then(async (s) => { + await (await s.createScript(fridaScripts.simulateHomeButton)).load(); + await s.detach(); + }); + }, }); +type CloudConfigurationDetails = Partial<{ + AllowPairing: boolean; + IsSupervised: boolean; + OrganizationName: string; + SupervisorHostCertificates: Buffer[]; +}>; + /** The IDs of known permissions on iOS. */ export const iosPermissions = [ 'kTCCServiceLiverpool', diff --git a/src/types/forge.d.ts b/src/types/forge.d.ts new file mode 100644 index 0000000..990c4b7 --- /dev/null +++ b/src/types/forge.d.ts @@ -0,0 +1,7 @@ +import type forge from 'node-forge'; + +declare module 'node-forge' { + namespace pki { + function distinguishedNameToAsn1(dn: forge.pki.Certificate['subject' | 'issuer']): forge.pki.Asn1; + } +} diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 0000000..8c02d93 --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,93 @@ +import { readFile } from 'fs/promises'; +import forge from 'node-forge'; +const { pki, md } = forge; + +export const generateCertificate = async (commonName: string, days?: number) => { + const keyPair = await new Promise((res, rej) => { + pki.rsa.generateKeyPair({ bits: 2048 }, (err, keyPair) => (err ? rej(err) : res(keyPair))); + }); + const cert = pki.createCertificate(); + + cert.publicKey = keyPair.publicKey; + cert.version = 2; + cert.serialNumber = Date.now().toString(10); + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + (days || 365)); + + const attributes = [ + { + name: 'commonName', + value: commonName, + }, + ]; + cert.setSubject(attributes); + cert.setIssuer(attributes); + + cert.sign(keyPair.privateKey, md.sha256.create()); + + return { + certificate: pki.certificateToPem(cert), + privateKey: pki.privateKeyToPem(keyPair.privateKey), + }; +}; + +export const certificateFingerprint = (certificatePem: string, hashAlgorithm?: 'SHA-256' | 'SHA-1') => { + const cert = pki.certificateFromPem(certificatePem); + return pki.getPublicKeyFingerprint(cert.publicKey, { + type: 'SubjectPublicKeyInfo', + md: hashAlgorithm === 'SHA-1' ? md.sha1.create() : md.sha256.create(), + encoding: 'hex', + }); +}; + +export const certificateHasExpired = (certificatePem: string) => { + const cert = pki.certificateFromPem(certificatePem); + return cert.validity.notAfter < new Date(); +}; + +export const createPkcs12Container = ( + certPem: string, + keyPem: string, + password?: string, + algorithm?: 'aes256' | '3des' +) => { + const p12 = forge.pkcs12.toPkcs12Asn1( + pki.privateKeyFromPem(keyPem), + pki.certificateFromPem(certPem), + password || '', + { algorithm: algorithm || '3des' } // Apparently any sane algorithm is not supported by the typical ingestors (like go), so we default to 3des + ); + + return forge.asn1.toDer(p12); +}; + +export const arrayBufferToPem = (buffer: ArrayBuffer, tag: 'CERTIFICATE' | 'PRIVATE KEY' | 'PUBLIC KEY') => { + const base64 = Buffer.from(buffer).toString('base64'); + return `-----BEGIN ${tag}-----\n${base64.replace(/(.{64})/g, '$1\n').trim()}\n-----END ${tag}-----`; // Thanks Copilot! +}; + +export const pemToArrayBuffer = (pem: string) => { + const base64 = pem + .replace(/-----BEGIN (.*)-----/, '') + .replace(/-----END (.*)-----/, '') + .replace(/\n/g, ''); + return Uint8Array.from(Buffer.from(base64, 'base64')).buffer; +}; + +export const parsePemCertificateFromFile = async (path: string) => { + const certPem = await readFile(path, 'utf8'); + const cert = pki.certificateFromPem(certPem); + + return { cert, certPem, certDer: Buffer.from(pemToArrayBuffer(certPem)) }; +}; + +export const certSubjectToAsn1 = (cert: forge.pki.Certificate) => forge.pki.distinguishedNameToAsn1(cert.subject); + +export const asn1ValueToDer = (asn1: forge.asn1.Asn1) => + typeof asn1.value === 'string' + ? forge.util.createBuffer(asn1.value) + : asn1.value.reduce((acc, cur) => { + acc.putBuffer(forge.asn1.toDer(cur)); + return acc; + }, forge.util.createBuffer()); diff --git a/src/util.ts b/src/utils/index.ts similarity index 68% rename from src/util.ts rename to src/utils/index.ts index b334b42..b55d335 100644 --- a/src/util.ts +++ b/src/utils/index.ts @@ -2,17 +2,12 @@ import { runAndroidDevTool } from 'andromatic'; import { fileTypeFromFile } from 'file-type'; import type { TargetProcess } from 'frida'; import frida from 'frida'; -import { createWriteStream } from 'fs'; import fs from 'fs-extra'; -import type { FileHandle } from 'fs/promises'; -import { open, readFile } from 'fs/promises'; +import { open } from 'fs/promises'; import _ipaInfo from 'ipa-extract-info'; -import { Certificate } from 'pkijs'; -import type { Readable } from 'stream'; -import { temporaryFile } from 'tempy'; -import type { Entry, ZipFile } from 'yauzl'; -import { fromFd } from 'yauzl'; -import type { AppPath, SupportedPlatform } from './index'; + +import type { AppPath, SupportedPlatform } from '../index'; +import { getFileFromZip, writeFileFromZipToTmp } from './zip'; // eslint-disable-next-line @typescript-eslint/no-empty-function export const asyncNop = async () => {}; @@ -208,104 +203,6 @@ export const getObjFromFridaScript = async (targetProcess: TargetProcess | undef export const isRecord = (maybeRecord: unknown): maybeRecord is Record => !!maybeRecord && typeof maybeRecord === 'object'; -/** - * Promise wrapper for yauzl.fromFd. - * - * @param zip FileHandle of the zip file. - * - * @returns ZipFile to be used with yauzl. - */ -export const openZipFile = async (zip: FileHandle) => - new Promise((resolve) => { - fromFd(zip.fd, { lazyEntries: true }, (err, zipFile) => { - if (err) throw err; - resolve(zipFile); - }); - }); - -/** - * Run a function on each entry in a zip file. Resolves if all entries have been processed. - * - * @param zip FileHandle of the zip file. - * @param callback Function to run on each entry, it will receive the entry and a reference to the current ZipFile. - */ -export const forEachInZip = async (zip: FileHandle, callback: (entry: Entry, zipFile: ZipFile) => Promise) => - openZipFile(zip).then( - (zipFile) => - new Promise((resolve) => { - zipFile.readEntry(); - zipFile.on('entry', (entry: Entry) => { - callback(entry, zipFile).then(() => zipFile.readEntry()); - }); - zipFile.on('end', () => resolve()); - }) - ); - -/** - * Get a Readable stream of a file entry in a zip file. - * - * @param zip FileHandle of the zip file. - * @param filename Name of the file entry in the zip. - * - * @returns A Readable stream of the file entry, or void if the file entry was not found. - */ -export const getFileFromZip = async (zip: FileHandle, filename: string) => - openZipFile(zip).then( - (zipFile) => - new Promise((resolve) => { - zipFile.readEntry(); - zipFile.on('entry', (entry: Entry) => { - if (entry.fileName !== filename) { - zipFile.readEntry(); - return; - } - zipFile.openReadStream(entry, (err, stream) => { - if (err) throw err; - resolve(stream); - }); - }); - zipFile.on('end', () => resolve()); - }) - ); - -export const writeFileFromZipToTmp = async (zip: FileHandle, filename: string) => - openZipFile(zip).then( - (zipFile) => - new Promise((resolve) => { - zipFile.readEntry(); - zipFile.on('entry', (entry: Entry) => { - if (entry.fileName !== filename) { - zipFile.readEntry(); - return; - } - tmpFileFromZipEntry(zipFile, entry).then((tmpFile) => resolve(tmpFile)); - }); - zipFile.on('end', () => resolve()); - }) - ); - -/** - * Write the contents of a zip entry to a temporary file. - * - * @param zipFile Yauzl ZipFile to read from. - * @param entry Entry in the zip file. - * @param extension Optional file extension to use for the temporary file. - * - * @returns The file name of the temporary file. - */ -export const tmpFileFromZipEntry = async ( - zipFile: ZipFile, - entry: Entry, - extension?: Extension -) => - new Promise<`${string}.${Extension}`>((resolve) => { - zipFile.openReadStream(entry, (err, stream) => { - if (err) throw Error; - const tmpFile = temporaryFile({ extension }) as `${string}.${Extension}`; - stream.pipe(createWriteStream(tmpFile).on('finish', () => resolve(tmpFile))); - }); - }); - // Taken from: https://stackoverflow.com/a/67605309 // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ParametersExceptFirst = F extends (arg0: any, ...rest: infer R) => any ? R : never; @@ -315,16 +212,6 @@ export type XapkManifest = { split_apks?: { file: string; id: string }[]; }; -export const parsePemCertificateFromFile = async (path: string) => { - const certPem = await readFile(path, 'utf8'); - - // A PEM certificate is just a base64-encoded DER certificate with a header and footer. - const certBase64 = certPem.replace(/(-----(BEGIN|END) CERTIFICATE-----|[\r\n])/g, ''); - const certDer = Buffer.from(certBase64, 'base64'); - - return { cert: Certificate.fromBER(certDer), certPem, certDer }; -}; - // I adapted this from https://github.com/sindresorhus/execa/blob/b44d4066aebd2db0c7864936e710b9fa6f5ab9d2/lib/command.js#L12-L25 const NO_ESCAPE_REGEXP = /^[\w.-]+$/; diff --git a/src/utils/zip.ts b/src/utils/zip.ts new file mode 100644 index 0000000..34e269f --- /dev/null +++ b/src/utils/zip.ts @@ -0,0 +1,104 @@ +import { createWriteStream } from 'fs'; +import type { FileHandle } from 'fs/promises'; +import type { Readable } from 'stream'; +import { temporaryFile } from 'tempy'; +import type { Entry, ZipFile } from 'yauzl'; +import { fromFd } from 'yauzl'; + +/** + * Promise wrapper for yauzl.fromFd. + * + * @param zip FileHandle of the zip file. + * + * @returns ZipFile to be used with yauzl. + */ +export const openZipFile = async (zip: FileHandle) => + new Promise((resolve) => { + fromFd(zip.fd, { lazyEntries: true }, (err, zipFile) => { + if (err) throw err; + resolve(zipFile); + }); + }); + +/** + * Run a function on each entry in a zip file. Resolves if all entries have been processed. + * + * @param zip FileHandle of the zip file. + * @param callback Function to run on each entry, it will receive the entry and a reference to the current ZipFile. + */ +export const forEachInZip = async (zip: FileHandle, callback: (entry: Entry, zipFile: ZipFile) => Promise) => + openZipFile(zip).then( + (zipFile) => + new Promise((resolve) => { + zipFile.readEntry(); + zipFile.on('entry', (entry: Entry) => { + callback(entry, zipFile).then(() => zipFile.readEntry()); + }); + zipFile.on('end', () => resolve()); + }) + ); + +/** + * Get a Readable stream of a file entry in a zip file. + * + * @param zip FileHandle of the zip file. + * @param filename Name of the file entry in the zip. + * + * @returns A Readable stream of the file entry, or void if the file entry was not found. + */ +export const getFileFromZip = async (zip: FileHandle, filename: string) => + openZipFile(zip).then( + (zipFile) => + new Promise((resolve) => { + zipFile.readEntry(); + zipFile.on('entry', (entry: Entry) => { + if (entry.fileName !== filename) { + zipFile.readEntry(); + return; + } + zipFile.openReadStream(entry, (err, stream) => { + if (err) throw err; + resolve(stream); + }); + }); + zipFile.on('end', () => resolve()); + }) + ); + +export const writeFileFromZipToTmp = async (zip: FileHandle, filename: string) => + openZipFile(zip).then( + (zipFile) => + new Promise((resolve) => { + zipFile.readEntry(); + zipFile.on('entry', (entry: Entry) => { + if (entry.fileName !== filename) { + zipFile.readEntry(); + return; + } + tmpFileFromZipEntry(zipFile, entry).then((tmpFile) => resolve(tmpFile)); + }); + zipFile.on('end', () => resolve()); + }) + ); + +/** + * Write the contents of a zip entry to a temporary file. + * + * @param zipFile Yauzl ZipFile to read from. + * @param entry Entry in the zip file. + * @param extension Optional file extension to use for the temporary file. + * + * @returns The file name of the temporary file. + */ +export const tmpFileFromZipEntry = async ( + zipFile: ZipFile, + entry: Entry, + extension?: Extension +) => + new Promise<`${string}.${Extension}`>((resolve) => { + zipFile.openReadStream(entry, (err, stream) => { + if (err) throw Error; + const tmpFile = temporaryFile({ extension }) as `${string}.${Extension}`; + stream.pipe(createWriteStream(tmpFile).on('finish', () => resolve(tmpFile))); + }); + }); diff --git a/yarn.lock b/yarn.lock index 5ff3b22..d8957dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1337,6 +1337,13 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== +"@types/node-forge@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.2.tgz#4aee4f269961fe288fe6dc35ad0ec6f71646d2bc" + integrity sha512-TzX3ahoi9xbmaoT58smrBu7oa6dQXb/+PTNCslZyD/55tlJ/osofIMClzZsoo6buDFrg7e4DvVGkZqVgv6OLxw== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@^18.11.18": version "18.11.18" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f" @@ -1639,15 +1646,6 @@ asn1@^0.2.4: dependencies: safer-buffer "~2.1.0" -asn1js@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.5.tgz#5ea36820443dbefb51cc7f88a2ebb5b462114f38" - integrity sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ== - dependencies: - pvtsutils "^1.3.2" - pvutils "^1.1.3" - tslib "^2.4.0" - astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" @@ -1707,7 +1705,7 @@ before-after-hook@^2.2.0: resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== -big-integer@^1.6.7: +big-integer@1.6.x, big-integer@^1.6.7: version "1.6.51" resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg== @@ -1751,6 +1749,27 @@ bottleneck@^2.15.3: resolved "https://registry.yarnpkg.com/bottleneck/-/bottleneck-2.19.5.tgz#5df0b90f59fd47656ebe63c78a98419205cadd91" integrity sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw== +bplist-creator@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/bplist-creator/-/bplist-creator-0.1.0.tgz#018a2d1b587f769e379ef5519103730f8963ba1e" + integrity sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg== + dependencies: + stream-buffers "2.2.x" + +bplist-creator@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/bplist-creator/-/bplist-creator-0.1.1.tgz#ef638af058a7021e10ebfd557ffd73d95e6799fc" + integrity sha512-Ese7052fdWrxp/vqSJkydgx/1MdBnNOCV2XVfbmdGWD2H6EYza+Q4pyYSuVSnCUD22hfI/BFI4jHaC3NLXLlJQ== + dependencies: + stream-buffers "2.2.x" + +bplist-parser@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/bplist-parser/-/bplist-parser-0.3.1.tgz#e1c90b2ca2a9f9474cc72f6862bbf3fee8341fd1" + integrity sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA== + dependencies: + big-integer "1.6.x" + bplist-parser@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/bplist-parser/-/bplist-parser-0.1.1.tgz#d60d5dcc20cba6dc7e1f299b35d3e1f95dafbae6" @@ -1867,11 +1886,6 @@ buildcheck@0.0.5: resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.5.tgz#5b7c0830b25dc61422032eeb5c18bfcaa9eebb8d" integrity sha512-jYWpRy8eedl/JZqkOeq0X0bNcaK04hXKhIi4gYsDKZUJWRjJJWViYfsMXO0BJQ40zSLcdLoa+iqe48Kz2PtQag== -bytestreamjs@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/bytestreamjs/-/bytestreamjs-2.0.1.tgz#a32947c7ce389a6fa11a09a9a563d0a45889535e" - integrity sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ== - cachedir@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" @@ -4279,6 +4293,11 @@ node-fetch@^2.6.6, node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-forge@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== + node-gyp-build-optional-packages@5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.3.tgz#92a89d400352c44ad3975010368072b41ad66c17" @@ -4615,17 +4634,6 @@ pkg-dir@^5.0.0: dependencies: find-up "^5.0.0" -pkijs@^3.0.14: - version "3.0.14" - resolved "https://registry.yarnpkg.com/pkijs/-/pkijs-3.0.14.tgz#92571f61122fd1e0ccdbc328b6efab9467b0bc30" - integrity sha512-Fi9++44BaOY0VcOEJql27D/HzHIeMU9R48XclfL98Cp8Wh/gGfPbuS1RUwReHQHRIUfzW32eoNO1izxoBMZi6w== - dependencies: - asn1js "^3.0.5" - bytestreamjs "^2.0.0" - pvtsutils "^1.3.2" - pvutils "^1.1.3" - tslib "^2.4.0" - please-upgrade-node@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" @@ -4633,7 +4641,7 @@ please-upgrade-node@^3.2.0: dependencies: semver-compare "^1.0.0" -plist@^3.0.4: +plist@^3.0.4, plist@^3.0.5: version "3.0.6" resolved "https://registry.yarnpkg.com/plist/-/plist-3.0.6.tgz#7cfb68a856a7834bca6dbfe3218eb9c7740145d3" integrity sha512-WiIVYyrp8TD4w8yCvyeIr+lkmrGRd5u0VbRnU+tP/aRLxP/YadJUYOMZJ/6hIa3oUyVCsycXvtNRgd5XBJIbiA== @@ -4738,18 +4746,6 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -pvtsutils@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.2.tgz#9f8570d132cdd3c27ab7d51a2799239bf8d8d5de" - integrity sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ== - dependencies: - tslib "^2.4.0" - -pvutils@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" - integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== - queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -5115,6 +5111,15 @@ simple-get@^4.0.0: once "^1.3.1" simple-concat "^1.0.0" +simple-plist@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/simple-plist/-/simple-plist-1.3.1.tgz#16e1d8f62c6c9b691b8383127663d834112fb017" + integrity sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw== + dependencies: + bplist-creator "0.1.0" + bplist-parser "0.3.1" + plist "^3.0.5" + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -5236,6 +5241,11 @@ static-module@^2.2.0: static-eval "^2.0.0" through2 "~2.0.3" +stream-buffers@2.2.x: + version "2.2.0" + resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4" + integrity sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg== + string-argv@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"