-
-
Notifications
You must be signed in to change notification settings - Fork 3
/
index.ts
499 lines (451 loc) · 20.1 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
import EventEmitter from "events";
import fs from "fs";
import fetch from "node-fetch";
import os from "os";
import path from "path";
import { iCloudAuthenticationStore } from "./auth/authStore";
import { GSASRPAuthenticator } from "./auth/iCSRPAuthenticator.js";
import { AUTH_ENDPOINT, AUTH_HEADERS, DEFAULT_HEADERS, SETUP_ENDPOINT } from "./consts";
import { iCloudAccountDetailsService } from "./services/account";
import { iCloudCalendarService } from "./services/calendar";
import { iCloudDriveService } from "./services/drive";
import { iCloudFindMyService } from "./services/findMy";
import { iCloudPhotosService } from "./services/photos";
import { iCloudUbiquityService } from "./services/ubiquity";
import { AccountInfo } from "./types";
export type { iCloudAuthenticationStore } from "./auth/authStore";
export type { AccountInfo } from "./types";
/**
* These are the options that can be passed to the iCloud service constructor.
*/
export interface iCloudServiceSetupOptions {
/**
* The username of the iCloud account to log in to.
* Can be provided now (at construction time) or later (on iCloudService#authenticate).
*/
username?: string;
/**
* The password of the iCloud account to log in to.
* Can be provided now (at construction time) or later (on iCloudService#authenticate).
*/
password?: string;
/**
* Whether to save the credentials to the system's secret store.
* (i.e. Keychain on macOS)
*/
saveCredentials?: boolean;
/**
* Whether to store the trust-token to disk.
* This allows future logins to be done without MFA.
*/
trustDevice?: boolean;
/**
* The directory to store the trust-token in.
* Defaults to the ~/.icloud directory.
*/
dataDirectory?: string;
/**
* The authentication method to use.
* Currently defaults to 'legacy', however this may change in the future.
* @default "legacy"
*/
authMethod?: "legacy" | "srp";
}
/**
* The state of the iCloudService.
*/
export const enum iCloudServiceStatus {
// iCloudService#authenticate has not been called yet.
NotStarted = "NotStarted",
// Called after iCloudService#authenticate was called and local validation of the username & password was verified.
Started = "Started",
// The user needs to be prompted for the MFA code, which can be provided by calling iCloudService#provideMfaCode
MfaRequested = "MfaRequested",
// The MFA code was successfully validated.
Authenticated = "Authenticated",
// Authentication has succeeded.
Trusted = "Trusted",
// The iCloudService is ready for use.
Ready = "Ready",
// The authentication failed.
Error = "Error"
}
/**
* Information about the account's storage usage.
*/
export interface iCloudStorageUsage {
storageUsageByMedia: Array<{
mediaKey: string
displayLabel: string
displayColor: string
usageInBytes: number
}>
storageUsageInfo: {
compStorageInBytes: number
usedStorageInBytes: number
totalStorageInBytes: number
commerceStorageInBytes: number
}
quotaStatus: {
overQuota: boolean
haveMaxQuotaTier: boolean
"almost-full": boolean
paidQuota: boolean
}
familyStorageUsageInfo: {
mediaKey: string
displayLabel: string
displayColor: string
usageInBytes: number
familyMembers: Array<{
lastName: string
dsid: number
fullName: string
firstName: string
usageInBytes: number
id: string
appleId: string
}>
}
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* The main iCloud service class
* It serves as a central manager for logging in and exposes all other services.
* @example ```ts
const icloud = new iCloud({
username: "johnny.appleseed@icloud.com",
password: "hunter2",
saveCredentials: true,
trustDevice: true
});
await icloud.authenticate();
console.log(icloud.status);
if (icloud.status === "MfaRequested") {
await icloud.provideMfaCode("123456");
}
await icloud.awaitReady;
console.log(icloud.status);
console.log("Hello, " + icloud.accountInfo.dsInfo.fullName);
```
*/
export default class iCloudService extends EventEmitter {
/**
* The authentication store for this service instance.
* Manages cookies & trust tokens.
*/
authStore: iCloudAuthenticationStore;
/**
* The options for this service instance.
*/
options: iCloudServiceSetupOptions;
/**
* The status of the iCloudService.
*/
status: iCloudServiceStatus = iCloudServiceStatus.NotStarted;
/*
* Has PCS (private/protected cloud service?) enabled.
* The check is implemented by checking if the `isDeviceConsentedForPCS` key is present in the `requestWebAccessState` object.
*/
pcsEnabled?: boolean;
/**
* PCS access is granted.
*/
pcsAccess?: boolean;
/**
* Has ICRS (iCloud Recovery Service) disabled.
* This should only be true when iCloud Advanced Data Protection is enabled.
*/
ICDRSDisabled?: boolean;
accountInfo?: AccountInfo;
/**
* A promise that can be awaited that resolves when the iCloudService is ready.
* Will reject if an error occurs during authentication.
*/
awaitReady = new Promise((resolve, reject) => {
this.on(iCloudServiceStatus.Ready, resolve);
this.on(iCloudServiceStatus.Error, reject);
});
constructor(options: iCloudServiceSetupOptions) {
super();
this.options = options;
if (!this.options.dataDirectory) this.options.dataDirectory = path.join(os.homedir(), ".icloud");
this.authStore = new iCloudAuthenticationStore(options);
}
private _setState(state: iCloudServiceStatus, ...args: any[]) {
console.debug("[icloud] State changed to:", state);
this.status = state;
this.emit(state, ...args);
}
/**
* Authenticates to the iCloud service.
* If a username is not passed to this function, it will use the one provided to the options object in the constructor, failing that, it will find the first result in the system's keychain matching https://idmsa.apple.com
* The same applies to the password. If it is not provided to this function, the options object will be used, and then it will check the keychain for a keychain matching the email for idmsa.apple.com
* @param username The username to use instead of the one provided in this iCloudService's options
* @param password The password to use instead of the one provided in this iCloudService's options
*/
async authenticate(username?: string, password?: string) {
username = username || this.options.username;
password = password || this.options.password;
if (!username) {
try {
const saved = (await require("keytar").findCredentials("https://idmsa.apple.com"))[0];
if (!saved) throw new Error("Username was not provided and could not be found in keychain");
username = saved.account;
console.debug("[icloud] Username found in keychain:", username);
} catch (e) {
throw new Error("Username was not provided, and unable to use Keytar to find saved credentials" + e.toString());
}
}
if (typeof (username as any) !== "string") throw new TypeError("authenticate(username?: string, password?: string): 'username' was " + (username || JSON.stringify(username)).toString());
this.options.username = username;
if (!password) {
try {
password = await require("keytar").findPassword("https://idmsa.apple.com", username);
} catch (e) {
throw new Error("Password was not provided, and unable to use Keytar to find saved credentials" + e.toString());
}
}
if (typeof (password as any) !== "string") throw new TypeError("authenticate(username?: string, password?: string): 'password' was " + (password || JSON.stringify(password)).toString());
// hide password from console.log
Object.defineProperty(this.options, "password", {
enumerable: false, // hide it from for..in
value: password
});
if (!username) throw new Error("Username is required");
if (!password) throw new Error("Password is required");
if (!fs.existsSync(this.options.dataDirectory)) fs.mkdirSync(this.options.dataDirectory);
this.authStore.loadTrustToken(this.options.username);
this._setState(iCloudServiceStatus.Started);
try {
let authEndpoint = "signin";
let authData = {
accountName: this.options.username,
trustTokens: this.authStore.trustToken ? [this.authStore.trustToken] : [],
rememberMe: this.options.saveCredentials
} as any;
if (this.options.authMethod === "srp") {
const authenticator = new GSASRPAuthenticator(username);
const initData = await authenticator.getInit();
const initResponse = await fetch(AUTH_ENDPOINT + "signin/init", {
headers: AUTH_HEADERS, method: "POST", body: JSON.stringify(initData)
}).then((r) => r.json());
authData = {
...authData,
...(await authenticator.getComplete(password, initResponse))
};
authEndpoint = "signin/complete";
} else {
authData.password = this.options.password;
}
const authResponse = await fetch(AUTH_ENDPOINT + authEndpoint + "?isRememberMeEnabled=true", { headers: AUTH_HEADERS, method: "POST", body: JSON.stringify(authData) });
if (authResponse.status == 200) {
if (this.authStore.processAuthSecrets(authResponse)) {
this._setState(iCloudServiceStatus.Trusted);
this._getiCloudCookies();
} else {
throw new Error("Unable to process auth response!");
}
} else if (authResponse.status == 409) {
if (this.authStore.processAuthSecrets(authResponse))
this._setState(iCloudServiceStatus.MfaRequested);
else
throw new Error("Unable to process auth response!");
} else {
if (authResponse.status == 401)
throw new Error("Recieved 401 error. Incorrect password? (" + authResponse.status + ", " + await authResponse.text() + ")");
throw new Error("Invalid status code: " + authResponse.status + ", " + await authResponse.text());
}
} catch (e) {
this._setState(iCloudServiceStatus.Error, e);
throw e;
}
}
/**
* Call this to provide the MFA code that was sent to the user's devices.
* @param code The six digit MFA code.
*/
async provideMfaCode(code: string) {
if (typeof (code as any) !== "string") throw new TypeError("provideMfaCode(code: string): 'code' was " + code.toString());
code = code.replace(/\D/g, "");
if (code.length !== 6) console.warn("[icloud] Provided MFA wasn't 6-digits!");
if (!this.authStore.validateAuthSecrets())
throw new Error("Cannot provide MFA code without calling authenticate first!");
const authData = { securityCode: { code } };
const authResponse = await fetch(
AUTH_ENDPOINT + "verify/trusteddevice/securitycode",
{ headers: this.authStore.getMfaHeaders(), method: "POST", body: JSON.stringify(authData) }
);
if (authResponse.status == 204) {
this._setState(iCloudServiceStatus.Authenticated);
if (this.options.trustDevice) this._getTrustToken().then(this._getiCloudCookies.bind(this));
else this._getiCloudCookies();
} else {
throw new Error("Invalid status code: " + authResponse.status + " " + await authResponse.text());
}
}
private async _getTrustToken() {
if (!this.authStore.validateAuthSecrets())
throw new Error("Cannot get auth token without calling authenticate first!");
console.debug("[icloud] Trusting device");
const authResponse = await fetch(
AUTH_ENDPOINT + "2sv/trust",
{ headers: this.authStore.getMfaHeaders() }
);
if (this.authStore.processAccountTokens(this.options.username, authResponse))
this._setState(iCloudServiceStatus.Trusted);
else
console.error("[icloud] Unable to trust device!");
}
private async _getiCloudCookies() {
try {
const data = {
dsWebAuthToken: this.authStore.sessionToken,
trustToken: this.authStore.trustToken
};
const response = await fetch(SETUP_ENDPOINT, { headers: DEFAULT_HEADERS, method: "POST", body: JSON.stringify(data) });
if (response.status == 200) {
if (this.authStore.processCloudSetupResponse(response)) {
try {
this.accountInfo = await response.json();
} catch (e) {
console.warn("[icloud] Could not get account info:", e);
}
try {
await this.checkPCS();
} catch (e) {
console.warn("[icloud] Could not get PCS state:", e);
}
this._setState(iCloudServiceStatus.Ready);
try {
if (this.options.saveCredentials) require("keytar").setPassword("https://idmsa.apple.com", this.options.username, this.options.password);
} catch (e) {
console.warn("[icloud] Unable to save account credentials:", e);
}
} else {
throw new Error("Unable to process cloud setup response!");
}
} else {
throw new Error("Invalid status code: " + response.status);
}
} catch (e) {
this._setState(iCloudServiceStatus.Error, e);
throw e;
}
}
/**
* Updates the PCS state (iCloudService.pcsEnabled, iCloudService.pcsAccess, iCloudService.ICDRSDisabled).
*/
async checkPCS() {
const pcsTest = await fetch("https://setup.icloud.com/setup/ws/1/requestWebAccessState", { headers: this.authStore.getHeaders(), method: "POST" });
if (pcsTest.status == 200) {
const j = await pcsTest.json();
this.pcsEnabled = typeof j.isDeviceConsentedForPCS == "boolean";
this.pcsAccess = this.pcsEnabled ? j.isDeviceConsentedForPCS : true;
this.ICDRSDisabled = j.isICDRSDisabled || false;
} else {
throw new Error("checkPCS: response code " + pcsTest.status);
}
}
/**
* Requests PCS access to a specific service. Required to call before accessing any PCS protected services when iCloud Advanced Data Protection is enabled.
* @remarks Should only be called when iCloudService.ICDRSDisabled is `false`, however this function will check for you, and immediately return as it's not required..
* @experimental
* @param appName The service name to request access to.
*/
async requestServiceAccess(appName: "iclouddrive") {
await this.checkPCS();
if (!this.ICDRSDisabled) {
console.warn("[icloud] requestServiceAccess: ICRS is not disabled.");
return true;
}
if (!this.pcsAccess) {
const requestPcs = await fetch("https://setup.icloud.com/setup/ws/1/enableDeviceConsentForPCS", { headers: this.authStore.getHeaders(), method: "POST" });
const requestPcsJson = await requestPcs.json();
if (!requestPcsJson.isDeviceConsentNotificationSent)
throw new Error("Unable to request PCS access!");
}
while (!this.pcsAccess) {
await sleep(5000);
await this.checkPCS();
}
let pcsRequest = await fetch("https://setup.icloud.com/setup/ws/1/requestPCS", { headers: this.authStore.getHeaders(), method: "POST", body: JSON.stringify({ appName, derivedFromUserAction: true }) });
let pcsJson = await pcsRequest.json();
while (true) {
if (pcsJson.status == "success") {
break;
} else {
switch (pcsJson.message) {
case "Requested the device to upload cookies.":
case "Cookies not available yet on server.":
await sleep(5000);
break;
default:
console.error("[icloud] unknown PCS request state", pcsJson);
}
pcsRequest = await fetch("https://setup.icloud.com/setup/ws/1/requestPCS", { headers: this.authStore.getHeaders(), method: "POST", body: JSON.stringify({ appName, derivedFromUserAction: false }) });
pcsJson = await pcsRequest.json();
}
}
this.authStore.addCookies(pcsRequest.headers.raw()["set-cookie"]);
return true;
}
private _serviceCache: {[key: string]: any} = {};
/**
* A mapping of service names to their classes.
* This is used by {@link iCloudService.getService} to return the correct service class.
* @remarks You should **not** use this to instantiate services, use {@link iCloudService.getService} instead.
* @see {@link iCloudService.getService}
*/
serviceConstructors: {[key: string]: any} = {
account: iCloudAccountDetailsService,
findme: iCloudFindMyService,
ubiquity: iCloudUbiquityService,
drivews: iCloudDriveService,
calendar: iCloudCalendarService,
photos: iCloudPhotosService
};
// Returns an instance of the 'account' (Account Details) service.
getService(service: "account"): iCloudAccountDetailsService;
// Returns an instance of the 'findme' (Find My) service.
getService(service: "findme"): iCloudFindMyService;
/**
* Returns an instance of the 'ubiquity' (Legacy iCloud Documents) service.
* @deprecated
*/
getService(service: "ubiquity"): iCloudUbiquityService;
// Returns an instance of the 'drivews' (iCloud Drive) service.
getService(service: "drivews"): iCloudDriveService
// Returns an instance of the 'calendar' (iCloud Calendar) service.
getService(service: "calendar"): iCloudCalendarService
// Returns an instance of the 'photos' (iCloud Photos) service.
getService(service: "photos"): iCloudPhotosService
/**
* Returns an instance of the specified service. Results are cached, so subsequent calls will return the same instance.
* @param service The service name to return an instance of. Must be one of the keys in {@link iCloudService.serviceConstructors}.
* @returns {iCloudService}
*/
getService(service:string) {
if (!this.serviceConstructors[service]) throw new TypeError(`getService(service: string): 'service' was ${service.toString()}, must be one of ${Object.keys(this.serviceConstructors).join(", ")}`);
if (service === "photos")
this._serviceCache[service] = new this.serviceConstructors[service](this, this.accountInfo.webservices.ckdatabasews.url);
if (!this._serviceCache[service])
this._serviceCache[service] = new this.serviceConstructors[service](this, this.accountInfo.webservices[service].url);
return this._serviceCache[service];
}
private _storage;
/**
* Gets the storage usage data for the account.
* @param refresh Force a refresh of the storage usage data.
* @returns {Promise<iCloudStorageUsage>} The storage usage data.
*/
async getStorageUsage(refresh = false): Promise<iCloudStorageUsage> {
if (!refresh && this._storage) return this._storage;
const response = await fetch("https://setup.icloud.com/setup/ws/1/storageUsageInfo", { headers: this.authStore.getHeaders() });
const json = await response.json();
this._storage = json;
return this._storage;
}
}