Skip to content

Commit

Permalink
feat: Support for SRP/GSA based authentication flow
Browse files Browse the repository at this point in the history
Fixes #363
  • Loading branch information
steilerDev committed Oct 15, 2023
1 parent a9e5421 commit 84c1d91
Show file tree
Hide file tree
Showing 24 changed files with 678 additions and 70 deletions.
2 changes: 2 additions & 0 deletions .vscode/icloud-photos-sync.cspell
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ endfor
fcxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
foldered
fontawesome
foxt
Fqhjt
FVJQ
GBIE
Expand All @@ -61,6 +62,7 @@ materialx
mimo
mkdocs
mockfs
moreutils
ncipollo
Nerb
Nfrb
Expand Down
4 changes: 2 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
],
"preLaunchTask": "Build App",
"program": "${workspaceFolder}/app/src/main.ts",
"args": ["sync"],
"args": ["token"],
"outFiles": [
"${workspaceFolder}/app/build/out/**/*.js",
],
Expand Down Expand Up @@ -65,7 +65,7 @@
"--runInBand",
"--config", "jest.config.json",
//"--detectOpenHandles",
"test/unit/resources.validator.test.ts"
"test/unit/icloud.test.ts"
],
"env": {
"NODE_NO_WARNINGS": "1"
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ Please also check out [open and known issues](https://github.com/steilerDev/iclo

Please check the [contributing guidelines](https://github.com/steilerDev/icloud-photos-sync/blob/main/CONTRIBUTING.md) to learn how to engage with this project.

## Acknowledgments

- Special thanks to [@foxt](https://foxt.dev/) for helping with reverse engineering some tricky parts of the iCloud API ([GSA](https://github.com/steilerDev/icloud-photos-sync/issues/363) & [ADP](https://github.com/steilerDev/icloud-photos-sync/issues/202)) - check out his [iCloud.js](https://github.com/foxt/icloud.js) project in case you need to access other aspects of iCloud

### Release Workflow

<p align="center">
Expand Down
4 changes: 4 additions & 0 deletions app/build/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const schemaList = [
typeName: `SigninResponse`,
srcPath: `src/lib/resources/network-types.ts`,
allowAdditionalProperties: true,
}, {
typeName: `SigninInitResponse`,
srcPath: `src/lib/resources/network-types.ts`,
allowAdditionalProperties: true,
}, {
typeName: `ResendMFADeviceResponse`,
srcPath: `src/lib/resources/network-types.ts`,
Expand Down
2 changes: 1 addition & 1 deletion app/eslint.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"quote-props": ["error", "as-needed"],
"indent": ["error", 4],
"eol-last": ["error", "never"],
"new-cap": ["error", { "newIsCapExceptions": ["iCloud", "iCloudPhotos", "iCPSError", "default", "iCPSInfluxLineProtocolPoint"], "capIsNewExceptions": ["TTL", "BASE"] }],
"new-cap": ["error", { "newIsCapExceptions": ["iCloud", "iCloudCrypto", "iCloudPhotos", "iCPSError", "default", "iCPSInfluxLineProtocolPoint"], "capIsNewExceptions": ["TTL", "BASE"] }],
"tsdoc/syntax": "warn",
"no-negated-condition": "off",
"no-unused-vars": "off",
Expand Down
6 changes: 6 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@
"@backtrace-labs/javascript-cli": "^0.1.2",
"@jest/globals": "^29.6.2",
"@types/cli-progress": "^3.11.0",
"@types/inquirer": "^9.0.3",
"@types/jest": "^29.5.2",
"@types/mock-fs": "^4.13.1",
"@types/node": "^18.17.18",
"@types/tough-cookie": "^4.0.2",
"@types/inquirer": "^9.0.3",
"@typescript-eslint/eslint-plugin": "^6.7.2",
"@typescript-eslint/parser": "^6.7.2",
"axios-mock-adapter": "^1.21.5",
Expand All @@ -87,6 +87,7 @@
},
"dependencies": {
"@backtrace-labs/node": "^0.0.2",
"@foxt/js-srp": "^0.0.3-patch1",
"ajv": "^8.12.0",
"axios": "^1.2.2",
"axios-har-tracker": "^0.5.1",
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 @@ -41,4 +41,8 @@ export const SETUP_TIMEOUT: ErrorStruct = buildErrorStruct(

export const PCS_REQUEST_FAILED: ErrorStruct = buildErrorStruct(
name, prefix, `PCS_REQUEST_FAILED`, `Unable to acquire PCS cookies`,
);

export const SRP_INIT_FAILED: ErrorStruct = buildErrorStruct(
name, prefix, `SRP_INIT_FAILED`, `Unable to initialize SRP authentication protocol`,
);
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 @@ -8,6 +8,10 @@ export const RESOURCE_FILE: ErrorStruct = buildErrorStruct(
name, prefix, `RESOURCE_FILE`, `Unable to parse and validate resource file`,
);

export const SIGNIN_INIT_RESPONSE: ErrorStruct = buildErrorStruct(
name, prefix, `SIGNIN_INIT_RESPONSE`, `Unable to parse and validate signin init response`,
);

export const SIGNIN_RESPONSE: ErrorStruct = buildErrorStruct(
name, prefix, `SIGNIN_RESPONSE`, `Unable to parse and validate signin response`,
);
Expand Down
8 changes: 6 additions & 2 deletions app/src/app/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export type iCPSAppOptions = {
suppressWarnings: boolean,
exportMetrics: boolean,
region: Resources.Types.Region,
legacyLogin: boolean,
metadataRate: [number, number]
}

Expand Down Expand Up @@ -216,10 +217,13 @@ export function argParser(callback: (res: iCPSApp) => void): Command {
.env(`METADATA_RATE`)
.default([Infinity, 0], `Infinity/0`)
.argParser(commanderParseInterval))
.addOption(new Option(`--region <string>`, `Changes the iCloud region. Experimental support for iCloud China.`)
.addOption(new Option(`--region <string>`, `Changes the iCloud region.`)
.env(`REGION`)
.default(`world`)
.choices(Object.values(Resources.Types.Region)));
.choices(Object.values(Resources.Types.Region)))
.addOption(new Option(`--legacy-login`, `Enables plain text legacy login method.`)
.env(`LEGACY_LOGIN`)
.default(false));

program.command(`daemon`)
.action(async (_, command) => {
Expand Down
12 changes: 10 additions & 2 deletions app/src/app/icloud-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ abstract class iCloudApp extends iCPSApp {
*/
async acquireLibraryLock() {
const lockFilePath = path.join(Resources.manager().dataDir, LIBRARY_LOCK_FILE);
if (fs.existsSync(lockFilePath)) {
const lockFileExists = await fs.promises.stat(lockFilePath)
.then(stat => stat.isFile())
.catch(() => false);

if (lockFileExists) {
if (!Resources.manager().force) {
const lockingProcess = (await fs.promises.readFile(lockFilePath, `utf-8`)).toString();
throw new iCPSError(LIBRARY_ERR.LOCKED)
Expand All @@ -146,7 +150,11 @@ abstract class iCloudApp extends iCPSApp {
*/
async releaseLibraryLock() {
const lockFilePath = path.join(Resources.manager().dataDir, LIBRARY_LOCK_FILE);
if (!fs.existsSync(lockFilePath)) {
const lockFileExists = await fs.promises.stat(lockFilePath)
.then(stat => stat.isFile())
.catch(() => false);

if (!lockFileExists) {
return;
}

Expand Down
92 changes: 92 additions & 0 deletions app/src/lib/icloud/icloud.crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {Client, Hash, Mode, Srp, util} from "@foxt/js-srp";
import crypto from "crypto";
import {Resources} from "../resources/main.js";
import {SRPProtocol} from "../resources/network-types.js";

/**
* This class handles the GSA SRP protocol required for authentication
*/
export class iCloudCrypto {
/**
* A promise that will resolve to the srp client used for authentication
*/
srpClient: Promise<Client>;
/**
* Access to the underlying js-srp library
*/
srp: Srp;

/**
* Creates a new crypto object and initiates the ephemeral values for this session
*/
constructor() {
this.srp = new Srp(Mode.GSA, Hash.SHA256, 2048);
this.srpClient = this.srp.newClient(
Buffer.from(Resources.manager().username),
// Placeholder, until we can derive the password key using the server response
new Uint8Array(),
);
}

/**
* @returns A Promise that will resolve to the clients's ephemeral challenge used in the SRP protocol, formatted as a base64 string
*/
async getClientEphemeral(): Promise<string> {
return Buffer.from(
util.bytesFromBigint((await this.srpClient).A),
).toString(`base64`);
}

/**
* This function will use the PBKDF2 algorithm to derive the password key
* @param protocol - The protocol to use for hashing the password
* @param salt - The salt value to use for password hashing as a base64 string
* @param iterations - Number of iterations to use for key derivation
* @returns A Promise that will resolve to the derived password key
*/
async derivePassword(protocol: SRPProtocol, salt: string, iterations: number): Promise<Uint8Array> {
let passHash = new Uint8Array(await util.hash(this.srp.h, Buffer.from(Resources.manager().password)));
if (protocol === `s2k_fo`) {
passHash = Buffer.from(util.toHex(passHash));
}

const imported = await crypto.subtle.importKey(
`raw`,
passHash,
{name: `PBKDF2`},
false,
[`deriveBits`],
);

const derived = await crypto.subtle.deriveBits({
name: `PBKDF2`,
hash: `SHA-256`,
salt: Buffer.from(salt, `base64`),
iterations,
}, imported, 256);

return new Uint8Array(derived);
}

/**
* Generates the proof values required for authentication
* @param derivedPassword - The PBKDF2 derived password key
* @param serverPublicValue - The public value shared by the server - base64 encoded string
* @param salt - The salt value shared by the server - base64 encoded string
* @returns A tuple containing the proof values m1 and m2, formatted as base64 strings
*/
async getProofValues(derivedPassword: Uint8Array, serverPublicValue: string, salt: string): Promise<[m1: string, m2: string]> {
const client = await this.srpClient;
client.p = derivedPassword;
const m1 = Buffer.from(
await client.generate(Buffer.from(salt, `base64`), Buffer.from(serverPublicValue, `base64`)),
`hex`,
).toString(`base64`);

const m2 = Buffer.from(
await client.generateM2(),
).toString(`base64`);

return [m1, m2];
}
}
73 changes: 62 additions & 11 deletions app/src/lib/icloud/icloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {ENDPOINTS} from '../resources/network-types.js';
import {iCPSEventCloud, iCPSEventMFA, iCPSEventPhotos, iCPSEventRuntimeWarning} from '../resources/events-types.js';
import pTimeout from 'p-timeout';
import {jsonc} from 'jsonc';
import {iCloudCrypto} from './icloud.crypto.js';

/**
* This class holds the iCloud connection
Expand Down Expand Up @@ -95,8 +96,6 @@ export class iCloud {
Resources.logger(this).info(`Authenticating user`);
Resources.emit(iCPSEventCloud.AUTHENTICATION_STARTED);

const url = ENDPOINTS.AUTH.BASE + ENDPOINTS.AUTH.PATH.SIGNIN;

const config: AxiosRequestConfig = {
params: {
isRememberMeEnabled: `true`,
Expand All @@ -105,15 +104,11 @@ export class iCloud {
validateStatus: status => status === 409 || status === 200,
};

const data = {
accountName: Resources.manager().username,
password: Resources.manager().password,
trustTokens: [
Resources.manager().trustToken,
],
};

try {
const [url, data] = Resources.manager().legacyLogin
? this.getLegacyLogin()
: await this.getSRPLogin();

const response = await Resources.network().post(url, data, config);

const validatedResponse = Resources.validator().validateSigninResponse(response);
Expand Down Expand Up @@ -167,6 +162,63 @@ export class iCloud {
}
}

/**
* Generates the legacy plain-text login payload and url
* @returns A tuple containing the url and payload required for the legacy login method
*/
getLegacyLogin(): [url: string, payload: any] {
Resources.logger(this).info(`Generating plain text login payload`);
return [
ENDPOINTS.AUTH.BASE + ENDPOINTS.AUTH.PATH.SIGNIN.LEGACY,
{
accountName: Resources.manager().username,
password: Resources.manager().password,
trustTokens: [
Resources.manager().trustToken,
],
},
];
}

/**
* Generates the SRP login payload and url from the iCloud server challenge
* @param authenticator - The authenticator crypto instance for generating the SRP proof - parameterized for testing purposes, will be initiated by default
* @returns A tuple containing the url and payload required for the SRP login method
*/
async getSRPLogin(authenticator: iCloudCrypto = new iCloudCrypto()): Promise<[url: string, payload: any]> {
Resources.logger(this).info(`Generating SRP challenge`);
try {
const initResponse = await Resources.network().post(ENDPOINTS.AUTH.BASE + ENDPOINTS.AUTH.PATH.SIGNIN.INIT, {
a: await authenticator.getClientEphemeral(),
accountName: Resources.manager().username,
protocols: [
`s2k`,
`s2k_fo`,
],
});

const validatedInitResponse = Resources.validator().validateSigninInitResponse(initResponse);

const derivedPassword = await authenticator.derivePassword(validatedInitResponse.data.protocol, validatedInitResponse.data.salt, validatedInitResponse.data.iteration);
const [m1Proof, m2Proof] = await authenticator.getProofValues(derivedPassword, validatedInitResponse.data.b, validatedInitResponse.data.salt);

return [
ENDPOINTS.AUTH.BASE + ENDPOINTS.AUTH.PATH.SIGNIN.COMPLETE,
{
accountName: Resources.manager().username,
trustTokens: [
Resources.manager().trustToken,
],
m1: m1Proof,
m2: m2Proof,
c: validatedInitResponse.data.c,
},
];
} catch (err) {
throw new iCPSError(AUTH_ERR.SRP_INIT_FAILED).addCause(err);
}
}

/**
* This function will ask the iCloud backend, to re-send the MFA token, using the provided method and number
* @param method - The method to be used
Expand Down Expand Up @@ -268,7 +320,6 @@ export class iCloud {
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);
Expand Down
2 changes: 0 additions & 2 deletions app/src/lib/resources/network-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import {Resources} from "./main.js";
import {iCPSAppOptions} from "../../app/factory.js";
import {pEvent} from "p-event";
import {jsonc} from "jsonc";
import path from "path";
import {randomInt} from "crypto";

/**
* Object holding all necessary information for a specific header value, that needs to be reused across multiple requests
Expand Down

0 comments on commit 84c1d91

Please sign in to comment.