Skip to content

Commit

Permalink
feat: Support for Advanced Data Protection
Browse files Browse the repository at this point in the history
Fixes #202
  • Loading branch information
steilerDev committed Oct 1, 2023
1 parent b8cf54e commit eb234c9
Show file tree
Hide file tree
Showing 14 changed files with 524 additions and 43 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"--runInBand",
"--config", "jest.config.json",
//"--detectOpenHandles",
"test/unit/app.test.ts"
"test/unit/resources.validator.test.ts"
],
"env": {
"NODE_NO_WARNINGS": "1"
Expand Down
4 changes: 4 additions & 0 deletions app/build/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const schemaList = [
typeName: `SetupResponse`,
srcPath: `src/lib/resources/network-types.ts`,
allowAdditionalProperties: true,
}, {
typeName: `PCSResponse`,
srcPath: `src/lib/resources/network-types.ts`,
allowAdditionalProperties: true,
}, {
typeName: `PhotosSetupResponse`,
srcPath: `src/lib/resources/network-types.ts`,
Expand Down
4 changes: 4 additions & 0 deletions app/src/app/error/codes/icloud-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,8 @@ export const ACCOUNT_SETUP: ErrorStruct = buildErrorStruct(

export const SETUP_TIMEOUT: ErrorStruct = buildErrorStruct(
name, prefix, `SETUP_TIMEOUT`, `iCloud setup did not complete successfully within expected amount of time`,
);

export const PCS_REQUEST_FAILED: ErrorStruct = buildErrorStruct(
name, prefix, `PCS_REQUEST_FAILED`, `Unable to acquire PCS cookies`,
);
4 changes: 4 additions & 0 deletions app/src/app/error/codes/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export const SETUP_RESPONSE: ErrorStruct = buildErrorStruct(
name, prefix, `SETUP_RESPONSE`, `Unable to parse and validate setup response`,
);

export const PCS_RESPONSE: ErrorStruct = buildErrorStruct(
name, prefix, `PCS_RESPONSE`, `Unable to parse and validate PCS acquisition response`,
);

export const PHOTOS_SETUP_RESPONSE: ErrorStruct = buildErrorStruct(
name, prefix, `PHOTOS_SETUP_RESPONSE`, `Unable to parse and validate photos setup response`,
);
Expand Down
3 changes: 3 additions & 0 deletions app/src/app/event/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ export class CLIInterface {
})
.on(iCPSEventCloud.SESSION_EXPIRED, () => {
this.print(chalk.yellowBright(`Session expired, re-authenticating...`));
})
.on(iCPSEventCloud.PCS_REQUIRED, () => {
this.print(chalk.yellowBright(`Advanced Data Protection requires additional cookies, acquiring...`));
});

Resources.events(this)
Expand Down
5 changes: 4 additions & 1 deletion app/src/app/event/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,11 @@ export class ErrorHandler {
.on(iCPSEventCloud.ACCOUNT_READY, () => {
this.btClient.breadcrumbs.info(`ACCOUNT_READY`);
})
.on(iCPSEventCloud.SESSION_EXPIRED, async () => {
.on(iCPSEventCloud.SESSION_EXPIRED, () => {
this.btClient.breadcrumbs.info(`SESSION_EXPIRED`);
})
.on(iCPSEventCloud.PCS_REQUIRED, () => {
this.btClient.breadcrumbs.info(`PCS_REQUIRED`);
});

Resources.events(this)
Expand Down
5 changes: 5 additions & 0 deletions app/src/app/event/metrics-exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const FIELDS = {
MFA_NOT_PROVIDED: `MFA_NOT_PROVIDED`,
DEVICE_TRUSTED: `DEVICE_TRUSTED`,
ACCOUNT_READY: `ACCOUNT_READY`,
PCS_REQUIRED: `PCS_REQUIRED`,
SESSION_EXPIRED: `SESSION_EXPIRED`,
ICLOUD_READY: `ICLOUD_READY`,
SYNC_START: `SYNC_START`,
Expand Down Expand Up @@ -379,6 +380,10 @@ export class MetricsExporter {
.on(iCPSEventCloud.SESSION_EXPIRED, () => {
this.logDataPoint(new iCPSInfluxLineProtocolPoint()
.logStatus(FIELDS.STATUS.values.SESSION_EXPIRED));
})
.on(iCPSEventCloud.PCS_REQUIRED, () => {
this.logDataPoint(new iCPSInfluxLineProtocolPoint()
.logStatus(FIELDS.STATUS.values.PCS_REQUIRED));
});

Resources.events(this)
Expand Down
43 changes: 38 additions & 5 deletions app/src/lib/icloud/icloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export class iCloud {
})
.on(iCPSEventCloud.SESSION_EXPIRED, async () => {
await this.authenticate();
})
.on(iCPSEventCloud.PCS_REQUIRED, async () => {
await this.acquirePCSCookies();
});
}

Expand Down Expand Up @@ -255,24 +258,28 @@ export class iCloud {
* Acquiring necessary cookies from trust and auth token for further processing. Also gets the user specific domain to interact with the Photos backend
* @emits iCPSEventCloud.ACCOUNT_READY - When account is ready to be used
* @emits iCPSEventCloud.SESSION_EXPIRED - When the session token has expired
* @emits iCPSEventCloud.PCS_REQUIRED - When the account is setup using ADP and PCS cookies are required
* @emits iCPSEventCloud.ERROR - When an error occurs - provides iCPSError as argument
*/
async setupAccount() {
try {
Resources.logger(this).info(`Setting up iCloud connection`);

const url = ENDPOINTS.SETUP.BASE() + ENDPOINTS.SETUP.PATH.ACCOUNT;
const url = ENDPOINTS.SETUP.BASE() + ENDPOINTS.SETUP.PATH.ACCOUNT_LOGIN;
const data = {
dsWebAuthToken: Resources.manager().sessionSecret,
trustToken: Resources.manager().trustToken,
};

const response = await Resources.network().post(url, data);
const validatedResponse = Resources.validator().validateSetupResponse(response);
Resources.network().applySetupResponse(validatedResponse);

Resources.logger(this).debug(`Account ready`);
Resources.emit(iCPSEventCloud.ACCOUNT_READY);
if (Resources.network().applySetupResponse(validatedResponse)) {
Resources.logger(this).debug(`Account ready`);
Resources.emit(iCPSEventCloud.ACCOUNT_READY);
} else {
Resources.logger(this).debug(`PCS required, acquiring...`);
Resources.emit(iCPSEventCloud.PCS_REQUIRED);
}
} catch (err) {
if ((err as any).isAxiosError && err.response.status === 421) {
Resources.logger(this).debug(`Session token expired, re-acquiring...`);
Expand All @@ -284,6 +291,32 @@ export class iCloud {
}
}

/**
* Acquires PCS cookies for ADP accounts
* @emits iCPSEventCloud.ACCOUNT_READY - When account is ready to be used
* @emits iCPSEventCloud.ERROR - When an error occurs - provides iCPSError as argument
*/
async acquirePCSCookies() {
try {
Resources.logger(this).info(`Acquiring PCS cookies`);

const url = ENDPOINTS.SETUP.BASE() + ENDPOINTS.SETUP.PATH.REQUEST_PCS;
const data = {
appName: `photos`,
derivedFromUserAction: false,
};

const response = await Resources.network().post(url, data);
const validatedResponse = Resources.validator().validatePCSResponse(response);
Resources.network().applyPCSResponse(validatedResponse);

Resources.logger(this).debug(`Account ready with PCS cookies`);
Resources.emit(iCPSEventCloud.ACCOUNT_READY);
} catch (err) {
Resources.emit(iCPSEventCloud.ERROR, new iCPSError(AUTH_ERR.PCS_REQUEST_FAILED).addCause(err));
}
}

/**
* Creating iCloud Photos sub-class and linking it
* @emits iCPSEventCloud.ERROR - When an error occurs - provides iCPSError as argument
Expand Down
12 changes: 8 additions & 4 deletions app/src/lib/resources/events-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export enum iCPSEventCloud {
* Emitted when the iCloud authentication trusted this device and stored the trust token for future requests - provides the trust token as argument
*/
TRUSTED = `icloud-trusted`,
/**
* Emitted when ADP is enabled and PCS Cookies are required
*/
PCS_REQUIRED = `icloud-pcs_req`,
/**
* Emitted when the iCloud account information have been retrieved
*/
Expand All @@ -49,10 +53,10 @@ export enum iCPSEventCloud {
* Emitted if the session token expired
*/
SESSION_EXPIRED = `icloud-session_expired`,
/**
* Emitted when the iCloud connection has experienced an error - provides an iCPSError as argument
*/
ERROR = `icloud-error`,
/**
* Emitted when the iCloud connection has experienced an error - provides an iCPSError as argument
*/
ERROR = `icloud-error`,
}

/**
Expand Down
16 changes: 15 additions & 1 deletion app/src/lib/resources/network-manager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import axios, {AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig} from "axios";
import fs from "fs/promises";
import {createWriteStream} from "fs";
import {HEADER_KEYS, SigninResponse, COOKIE_KEYS, TrustResponse, SetupResponse, ENDPOINTS, PhotosSetupResponse, USER_AGENT, CLIENT_ID, CLIENT_INFO} from "./network-types.js";
import {HEADER_KEYS, SigninResponse, COOKIE_KEYS, TrustResponse, SetupResponse, ENDPOINTS, PhotosSetupResponse, USER_AGENT, CLIENT_ID, CLIENT_INFO, PCSResponse} from "./network-types.js";
import {Cookie} from "tough-cookie";
import {iCPSError} from "../../app/error/error.js";
import {RESOURCES_ERR} from "../../app/error/error-codes.js";
Expand Down Expand Up @@ -412,10 +412,24 @@ export class NetworkManager {
/**
* Applies configurations from the response received after the setup request. This includes setting the photos URL and persisting the iCloud authentication cookies.
* @param setupResponse - The response received from the server
* @returns True if necessary PCS cookies were found, false otherwise
*/
applySetupResponse(setupResponse: SetupResponse) {
this.photosUrl = setupResponse.data.webservices.ckdatabasews.url;
this._headerJar.setCookie(...setupResponse.headers[`set-cookie`]);
if(!setupResponse.data.webservices.ckdatabasews.pcsRequired) {
return true;
}
return [...this._headerJar.cookies.values()]
.filter(cookie => cookie.key === COOKIE_KEYS.PCS_PHOTOS || cookie.key === COOKIE_KEYS.PCS_SHARING).length === 2;
}

/**
* Applies the acquired PCS cookies received from the PCS request to the header jar.
* @param pcsResponse - The response received from the server
*/
applyPCSResponse(pcsResponse: PCSResponse) {
this._headerJar.setCookie(...pcsResponse.headers[`set-cookie`]);
}

/**
Expand Down
44 changes: 43 additions & 1 deletion app/src/lib/resources/network-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const HEADER_KEYS = {
export const COOKIE_KEYS = {
AASP: `aasp`,
X_APPLE: `X-APPLE-`,
PCS_PHOTOS: `X-APPLE-WEBAUTH-PCS-Photos`,
PCS_SHARING: `X-APPLE-WEBAUTH-PCS-Sharing`,
};

/**
Expand Down Expand Up @@ -85,7 +87,8 @@ export const ENDPOINTS = {
return `https://setup.${Resources.network().iCloudRegionUrl()}`;
},
PATH: {
ACCOUNT: `/setup/ws/1/accountLogin`,
ACCOUNT_LOGIN: `/setup/ws/1/accountLogin`,
REQUEST_PCS: `/setup/ws/1/requestPCS`,
},
},
/**
Expand Down Expand Up @@ -267,6 +270,10 @@ export type SetupResponse = {
* @minLength 1
*/
url: string,
/**
* Shows if additional PCS cookies are required - if missing not necessary
*/
pcsRequired?: boolean,
/**
* Service needs to be active
*/
Expand All @@ -276,6 +283,41 @@ export type SetupResponse = {
}
}

/**
* The expected response when trying to acquire PCS cookies
*/
export type PCSResponse = {
headers: {
/**
* Should hold the PCS cookies
* @minItems 2
*/
'set-cookie': string[], // eslint-disable-line
}
data: {
/**
* Needs to be yes, otherwise this tool will not work
*/
isWebAccessAllowed: true,
/**
* Consent needs to be previously provided, not yet supported by this tool
*/
isDeviceConsentedForPCS: true,
/**
* There is also the case of "Requested the device to upload cookies." - not sure though what to do in this case, as I'll be sending all available cookies
*/
message: `Cookies attached.` | `Cookies already present.`,
/**
* Gives a unix timestamp when the PCS consents expires
*/
deviceConsentForPCSExpiry: number,
/**
* Can also be "failure" not sure how to handle this though
*/
status: `success`
}
}

/**
* The expected response format for the photos setup request
*/
Expand Down
35 changes: 34 additions & 1 deletion app/src/lib/resources/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import SetupResponseSchema from "./schemas/setup-response.json" assert { type: "
import PhotosSetupResponseSchema from "./schemas/photos-setup-response.json" assert { type: "json" }; // eslint-disable-line
import ResendMFADeviceResponseSchema from "./schemas/resend-mfa-device-response.json" assert { type: "json" }; // eslint-disable-line
import ResendMFAPhoneResponseSchema from "./schemas/resend-mfa-phone-response.json" assert { type: "json" }; // eslint-disable-line
import PCSResponseSchema from "./schemas/pcs-response.json" assert { type: "json" }; // eslint-disable-line
import {ResourceFile} from "./resource-types.js";
import {iCPSError} from "../../app/error/error.js";
import {ErrorStruct, VALIDATOR_ERR} from "../../app/error/error-codes.js";
import {COOKIE_KEYS, PhotosSetupResponse, ResendMFADeviceResponse, ResendMFAPhoneResponse, SetupResponse, SigninResponse, TrustResponse} from "./network-types.js";
import {COOKIE_KEYS, PCSResponse, PhotosSetupResponse, ResendMFADeviceResponse, ResendMFAPhoneResponse, SetupResponse, SigninResponse, TrustResponse} from "./network-types.js";

/**
* Common configuration for the schema validator
Expand Down Expand Up @@ -54,6 +55,11 @@ export class Validator {
*/
_setupResponseValidator: Ajv.ValidateFunction<SetupResponse> = new Ajv.default(AJV_CONF).compile<SetupResponse>(SetupResponseSchema);

/**
* Validator for the PCS response schema
*/
_pcsResponseValidator: Ajv.ValidateFunction<PCSResponse> = new Ajv.default(AJV_CONF).compile<PCSResponse>(PCSResponseSchema);

/**
* Validator for the iCloud photos setup response schema
*/
Expand Down Expand Up @@ -113,6 +119,12 @@ export class Validator {
);
}

/**
* Validates the response from resending the MFA code via a device
* @param data - The data to validate
* @returns A validated ResendMFADeviceResponse object
* @throws An error if the data cannot be validated
*/
validateResendMFADeviceResponse(data: unknown): ResendMFADeviceResponse {
return this.validate(
this._resendMFADeviceResponseValidator,
Expand All @@ -121,6 +133,12 @@ export class Validator {
);
}

/**
* Validates the response from resending the MFA code via a phone
* @param data - The data to validate
* @returns A validated ResendMFAPhoneResponse object
* @throws An error if the data cannot be validated
*/
validateResendMFAPhoneResponse(data: unknown): ResendMFAPhoneResponse {
return this.validate(
this._resendMFAPhoneResponseValidator,
Expand Down Expand Up @@ -158,6 +176,21 @@ export class Validator {
);
}

/**
* Validates the response from the PCS acquisition request
* @param data - The data to validate
* @returns A validated PCSResponse object
* @throws An error if the data cannot be validated
*/
validatePCSResponse(data: unknown): PCSResponse {
return this.validate(
this._pcsResponseValidator,
VALIDATOR_ERR.PCS_RESPONSE,
data,
(data: PCSResponse) => data.headers[`set-cookie`].filter(cookieString => cookieString.startsWith(COOKIE_KEYS.PCS_PHOTOS) || cookieString.startsWith(COOKIE_KEYS.PCS_SHARING)).length === 2,
);
}

/**
* Validates the response from the photos setup request
* @param data - The data to validate
Expand Down

0 comments on commit eb234c9

Please sign in to comment.