From 1c27c2bf17a7df1fcfa434b7c04fda3a41f38a34 Mon Sep 17 00:00:00 2001 From: Jason Cassidy <47318351+jcassidyav@users.noreply.github.com> Date: Sat, 25 Mar 2023 13:05:55 +0000 Subject: [PATCH] fix(publish): various apple publish/sign-in fixes (#5718) --- lib/commands/appstore-upload.ts | 22 ++-- lib/constants.ts | 1 + lib/declarations.d.ts | 7 +- .../apple-portal-session-service.ts | 111 ++++++++++++++++-- lib/services/apple-portal/definitions.d.ts | 1 + lib/services/itmstransporter-service.ts | 90 +++++++++++++- 6 files changed, 204 insertions(+), 28 deletions(-) diff --git a/lib/commands/appstore-upload.ts b/lib/commands/appstore-upload.ts index b3aebffedd..3af7437801 100644 --- a/lib/commands/appstore-upload.ts +++ b/lib/commands/appstore-upload.ts @@ -40,15 +40,16 @@ export class PublishIOS implements ICommand { } public async execute(args: string[]): Promise { - await this.$itmsTransporterService.validate(); + await this.$itmsTransporterService.validate( + this.$options.appleApplicationSpecificPassword + ); const username = args[0] || (await this.$prompter.getString("Apple ID", { allowEmpty: false })); + const password = args[1] || (await this.$prompter.getPassword("Apple ID password")); - const mobileProvisionIdentifier = args[2]; - const codeSignIdentity = args[3]; const user = await this.$applePortalSessionService.createUserSession( { username, password }, @@ -66,6 +67,8 @@ export class PublishIOS implements ICommand { ); } + const mobileProvisionIdentifier = this.$options.provision ?? args[2]; + let ipaFilePath = this.$options.ipa ? path.resolve(this.$options.ipa) : null; @@ -76,25 +79,21 @@ export class PublishIOS implements ICommand { ); } - if (!codeSignIdentity && !ipaFilePath) { - this.$logger.warn( - "No code sign identity set. A default code sign identity will be used. You can set one in app/App_Resources/iOS/build.xcconfig" - ); - } - this.$options.release = true; if (!ipaFilePath) { const platform = this.$devicePlatformsConstants.iOS.toLowerCase(); // No .ipa path provided, build .ipa on out own. - if (mobileProvisionIdentifier || codeSignIdentity) { + if (mobileProvisionIdentifier) { // This is not very correct as if we build multiple targets we will try to sign all of them using the signing identity here. this.$logger.info( - "Building .ipa with the selected mobile provision and/or certificate." + "Building .ipa with the selected mobile provision and/or certificate. " + + mobileProvisionIdentifier ); // As we need to build the package for device this.$options.forDevice = true; + this.$options.provision = mobileProvisionIdentifier; const buildData = new IOSBuildData( this.$projectData.projectDir, @@ -124,6 +123,7 @@ export class PublishIOS implements ICommand { ipaFilePath, shouldExtractIpa: !!this.$options.ipa, verboseLogging: this.$logger.getLevel() === "TRACE", + teamId: this.$options.teamId, }); } diff --git a/lib/constants.ts b/lib/constants.ts index 04b8fe6836..bbb8f8dcbc 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -158,6 +158,7 @@ export class ITMSConstants { }; static iTMSExecutableName = "iTMSTransporter"; static iTMSDirectoryName = "itms"; + static altoolExecutableName = "altool"; } class ItunesConnectApplicationTypesClass diff --git a/lib/declarations.d.ts b/lib/declarations.d.ts index ef9a301137..e27b2729cd 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -765,13 +765,18 @@ interface IITMSData { * @type {string} */ verboseLogging?: boolean; + /** + * Specifies the team id + * @type {string} + */ + teamId?: string; } /** * Used for communicating with Xcode iTMS Transporter tool. */ interface IITMSTransporterService { - validate(): Promise; + validate(appSpecificPassword?: string): Promise; /** * Uploads an .ipa package to iTunes Connect. * @param {IITMSData} data Data needed to upload the package diff --git a/lib/services/apple-portal/apple-portal-session-service.ts b/lib/services/apple-portal/apple-portal-session-service.ts index b376741049..197d60f47e 100644 --- a/lib/services/apple-portal/apple-portal-session-service.ts +++ b/lib/services/apple-portal/apple-portal-session-service.ts @@ -8,6 +8,7 @@ import { IAppleLoginResult, IApplePortalSessionService, } from "./definitions"; +import * as crypto from "crypto"; export class ApplePortalSessionService implements IApplePortalSessionService { private loginConfigEndpoint = @@ -38,7 +39,8 @@ export class ApplePortalSessionService implements IApplePortalSessionService { await this.handleTwoFactorAuthentication( loginResult.scnt, loginResult.xAppleIdSessionId, - authServiceKey + authServiceKey, + loginResult.hashcash ); } @@ -114,6 +116,7 @@ export class ApplePortalSessionService implements IApplePortalSessionService { xAppleIdSessionId: null, isTwoFactorAuthenticationEnabled: false, areCredentialsValid: true, + hashcash: null, }; if (opts && opts.sessionBase64) { @@ -130,6 +133,12 @@ export class ApplePortalSessionService implements IApplePortalSessionService { await this.loginCore(credentials); } catch (err) { const statusCode = err && err.response && err.response.status; + + const bits = err?.response?.headers["x-apple-hc-bits"]; + const challenge = err?.response?.headers["x-apple-hc-challenge"]; + const hashcash = makeHashCash(bits, challenge); + result.hashcash = hashcash; + result.areCredentialsValid = statusCode !== 401 && statusCode !== 403; result.isTwoFactorAuthenticationEnabled = statusCode === 409; @@ -216,12 +225,14 @@ For more details how to set up your environment, please execute "tns publish ios private async handleTwoFactorAuthentication( scnt: string, xAppleIdSessionId: string, - authServiceKey: string + authServiceKey: string, + hashcash: string ): Promise { const headers = { scnt: scnt, "X-Apple-Id-Session-Id": xAppleIdSessionId, "X-Apple-Widget-Key": authServiceKey, + "X-Apple-HC": hashcash, Accept: "application/json", }; const authResponse = await this.$httpClient.httpRequest({ @@ -231,21 +242,48 @@ For more details how to set up your environment, please execute "tns publish ios }); const data = JSON.parse(authResponse.body); - if (data.trustedPhoneNumbers && data.trustedPhoneNumbers.length) { + + const isSMS = + data.trustedPhoneNumbers && + data.trustedPhoneNumbers.length === 1 && + data.noTrustedDevices; // 1 device and no trusted devices means sms was automatically sent. + const multiSMS = + data.trustedPhoneNumbers && + data.trustedPhoneNumbers.length !== 1 && + data.noTrustedDevices; // Not handling more than 1 sms device and no trusted devices. + + let token: string; + + if ( + data.trustedPhoneNumbers && + data.trustedPhoneNumbers.length && + !multiSMS + ) { const parsedAuthResponse = JSON.parse(authResponse.body); - const token = await this.$prompter.getString( + token = await this.$prompter.getString( `Please enter the ${parsedAuthResponse.securityCode.length} digit code`, { allowEmpty: false } ); + const body: any = { + securityCode: { + code: token.toString(), + }, + }; + let url = `https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode`; + + if (isSMS) { + // No trusted devices means it must be sms. + body.mode = "sms"; + body.phoneNumber = { + id: data.trustedPhoneNumbers[0].id, + }; + url = `https://idmsa.apple.com/appleauth/auth/verify/phone/securitycode`; + } await this.$httpClient.httpRequest({ - url: `https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode`, + url, method: "POST", - body: { - securityCode: { - code: token.toString(), - }, - }, + body, headers: { ...headers, "Content-Type": "application/json" }, }); @@ -258,6 +296,10 @@ For more details how to set up your environment, please execute "tns publish ios this.$applePortalCookieService.updateUserSessionCookie( authTrustResponse.headers["set-cookie"] ); + } else if (multiSMS) { + this.$errors.fail( + `The NativeScript CLI does not support SMS authenticaton with multiple registered phone numbers.` + ); } else { this.$errors.fail( `Although response from Apple indicated activated Two-step Verification or Two-factor Authentication, NativeScript CLI don't know how to handle this response: ${data}` @@ -266,3 +308,52 @@ For more details how to set up your environment, please execute "tns publish ios } } injector.register("applePortalSessionService", ApplePortalSessionService); + +function makeHashCash(bits: string, challenge: string): string { + const version = 1; + + const dateString = getHashCanDateString(); + let result: string; + for (let counter = 0; ; counter++) { + const hc = [version, bits, dateString, challenge, `:${counter}`].join(":"); + + const shasumData = crypto.createHash("sha1"); + + shasumData.update(hc); + const digest = shasumData.digest(); + if (checkBits(+bits, digest)) { + result = hc; + break; + } + } + return result; +} + +function getHashCanDateString(): string { + const now = new Date(); + + return `${now.getFullYear()}${padTo2Digits(now.getMonth() + 1)}${padTo2Digits( + now.getDate() + )}${padTo2Digits(now.getHours())}${padTo2Digits( + now.getMinutes() + )}${padTo2Digits(now.getSeconds())}`; +} +function padTo2Digits(num: number) { + return num.toString().padStart(2, "0"); +} + +function checkBits(bits: number, digest: Buffer) { + let result = true; + for (let i = 0; i < bits; ++i) { + result = checkBit(i, digest); + if (!result) break; + } + return result; +} + +function checkBit(position: number, buffer: Buffer): boolean { + const bitOffset = position & 7; // in byte + const byteIndex = position >> 3; // in buffer + const bit = (buffer[byteIndex] >> bitOffset) & 1; + return bit === 0; +} diff --git a/lib/services/apple-portal/definitions.d.ts b/lib/services/apple-portal/definitions.d.ts index d86a0e3de2..4efbb0675e 100644 --- a/lib/services/apple-portal/definitions.d.ts +++ b/lib/services/apple-portal/definitions.d.ts @@ -39,6 +39,7 @@ interface IAppleLoginResult { xAppleIdSessionId: string; isTwoFactorAuthenticationEnabled: boolean; areCredentialsValid: boolean; + hashcash: string; } interface IApplePortalUserDetail extends IAppleLoginResult { diff --git a/lib/services/itmstransporter-service.ts b/lib/services/itmstransporter-service.ts index 61b3b2bac8..27733a0d42 100644 --- a/lib/services/itmstransporter-service.ts +++ b/lib/services/itmstransporter-service.ts @@ -33,16 +33,38 @@ export class ITMSTransporterService implements IITMSTransporterService { return this.$injector.resolve("projectData"); } - public async validate(): Promise { + public async validate(appSpecificPassword?: string): Promise { const itmsTransporterPath = await this.getITMSTransporterPath(); - if (!this.$fs.exists(itmsTransporterPath)) { - this.$errors.fail( - "iTMS Transporter not found on this machine - make sure your Xcode installation is not damaged." - ); + const version = await this.$xcodeSelectService.getXcodeVersion(); + if (+version.major < 14) { + if (!this.$fs.exists(itmsTransporterPath)) { + this.$errors.fail( + "iTMS Transporter not found on this machine - make sure your Xcode installation is not damaged." + ); + } + } else { + const altoolPath = await this.getAltoolPath(); + if (!this.$fs.exists(altoolPath)) { + this.$errors.fail( + "altool not found on this machine - make sure your Xcode installation is not damaged." + ); + } + if (!appSpecificPassword) { + this.$errors.fail( + "An app-specific password is required from xCode versions 14 and above, Use the --appleApplicationSpecificPassword to supply it." + ); + } } } - public async upload(data: IITMSData): Promise { + const version = await this.$xcodeSelectService.getXcodeVersion(); + if (+version.major < 14) { + await this.upload_iTMSTransporter(data); + } else { + await this.upload_altool(data); + } + } + public async upload_iTMSTransporter(data: IITMSData): Promise { const itmsTransporterPath = await this.getITMSTransporterPath(); const ipaFileName = "app.ipa"; const itmsDirectory = await this.$tempService.mkdirSync("itms-"); @@ -99,6 +121,46 @@ export class ITMSTransporterService implements IITMSTransporterService { ); } + public async upload_altool(data: IITMSData): Promise { + const altoolPath = await this.getAltoolPath(); + const ipaFileName = "app.ipa"; + const itmsDirectory = await this.$tempService.mkdirSync("itms-"); + const innerDirectory = path.join(itmsDirectory, "mybundle.itmsp"); + const ipaFileLocation = path.join(innerDirectory, ipaFileName); + + this.$fs.createDirectory(innerDirectory); + + this.$fs.copyFile(data.ipaFilePath, ipaFileLocation); + + const password = data.applicationSpecificPassword; + + const args = [ + "--upload-app", + "-t", + "ios", + "-f", + ipaFileLocation, + "-u", + data.credentials.username, + "-p", + password, + "-k 100000", + ]; + + if (data.teamId) { + args.push("--asc-provider"); + args.push(data.teamId); + } + + if (data.verboseLogging) { + args.push("--verbose"); + } + + await this.$childProcess.spawnFromEvent(altoolPath, args, "close", { + stdio: "inherit", + }); + } + private async getBundleIdentifier(data: IITMSData): Promise { const { shouldExtractIpa, ipaFilePath } = data; @@ -156,6 +218,22 @@ export class ITMSTransporterService implements IITMSTransporterService { return this.$projectData.projectIdentifiers.ios; } + @cache() + private async getAltoolPath(): Promise { + const xcodePath = await this.$xcodeSelectService.getContentsDirectoryPath(); + let itmsTransporterPath = path.join( + xcodePath, + "..", + "Contents", + "Developer", + "usr", + "bin", + ITMSConstants.altoolExecutableName + ); + + return itmsTransporterPath; + } + @cache() private async getITMSTransporterPath(): Promise { const xcodePath = await this.$xcodeSelectService.getContentsDirectoryPath();