@hot-updater/apple-helper 1.2.0-rc.4 → 1.2.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +638 -345
- package/dist/index.d.cts +11 -10
- package/dist/index.d.mts +11 -10
- package/dist/index.mjs +715 -418
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -23,214 +23,555 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
23
23
|
//#endregion
|
|
24
24
|
let node_crypto = require("node:crypto");
|
|
25
25
|
node_crypto = __toESM(node_crypto, 1);
|
|
26
|
-
let
|
|
27
|
-
let node_fs_promises = require("node:fs/promises");
|
|
28
|
-
node_fs_promises = __toESM(node_fs_promises, 1);
|
|
29
|
-
let node_net = require("node:net");
|
|
30
|
-
node_net = __toESM(node_net, 1);
|
|
31
|
-
let node_os = require("node:os");
|
|
32
|
-
node_os = __toESM(node_os, 1);
|
|
33
|
-
let node_path = require("node:path");
|
|
34
|
-
node_path = __toESM(node_path, 1);
|
|
26
|
+
let node_timers_promises = require("node:timers/promises");
|
|
35
27
|
let _hot_updater_cli_tools = require("@hot-updater/cli-tools");
|
|
36
|
-
let execa = require("execa");
|
|
37
28
|
let fs = require("fs");
|
|
38
29
|
fs = __toESM(fs, 1);
|
|
39
30
|
let path = require("path");
|
|
40
31
|
path = __toESM(path, 1);
|
|
32
|
+
let execa = require("execa");
|
|
33
|
+
let node_fs = require("node:fs");
|
|
34
|
+
let node_fs_promises = require("node:fs/promises");
|
|
35
|
+
let node_os = require("node:os");
|
|
36
|
+
let node_path = require("node:path");
|
|
41
37
|
let child_process = require("child_process");
|
|
42
38
|
let crypto = require("crypto");
|
|
43
39
|
crypto = __toESM(crypto, 1);
|
|
44
40
|
let os = require("os");
|
|
45
41
|
os = __toESM(os, 1);
|
|
46
|
-
//#region src/asc/
|
|
42
|
+
//#region src/asc/appleSrp.ts
|
|
47
43
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
44
|
+
* Apple's sign-in proof: SRP-6a over the RFC 5054 2048-bit group with
|
|
45
|
+
* SHA-256, the password stretched with PBKDF2, as Apple's sign-in page and
|
|
46
|
+
* asc, the open-source App Store Connect CLI
|
|
47
|
+
* (https://github.com/rorkai/App-Store-Connect-CLI, MIT), compute it.
|
|
52
48
|
*/
|
|
53
|
-
|
|
49
|
+
/** RFC 5054, Appendix A, the 2048-bit group. */
|
|
50
|
+
const N = BigInt("0xAC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B855F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773BCA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73");
|
|
51
|
+
const G = 2n;
|
|
52
|
+
/** The hex length every padded value has: N's byte length, twice. */
|
|
53
|
+
const PADDED_HEX_LENGTH = 512;
|
|
54
|
+
const modPow = (base, exponent, modulus) => {
|
|
55
|
+
let result = 1n;
|
|
56
|
+
let b = (base % modulus + modulus) % modulus;
|
|
57
|
+
let e = exponent;
|
|
58
|
+
while (e > 0n) {
|
|
59
|
+
if (e & 1n) result = result * b % modulus;
|
|
60
|
+
b = b * b % modulus;
|
|
61
|
+
e >>= 1n;
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
/** An even-length lowercase hex of `value`, as the proof hashes it. */
|
|
66
|
+
const toHex = (value) => {
|
|
67
|
+
const hex = value.toString(16);
|
|
68
|
+
return hex.length % 2 === 1 ? `0${hex}` : hex;
|
|
69
|
+
};
|
|
70
|
+
const fromBytes = (bytes) => bytes.length === 0 ? 0n : BigInt(`0x${Buffer.from(bytes).toString("hex")}`);
|
|
71
|
+
const sha256Hex = (hex) => node_crypto.default.createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex");
|
|
72
|
+
/** SHA-256 of each value left-padded to N's length, as a number mod N. */
|
|
73
|
+
const paddedHash = (...values) => BigInt(`0x${sha256Hex(values.map((value) => value.padStart(PADDED_HEX_LENGTH, "0")).join(""))}`) % N;
|
|
74
|
+
/** The password as the protocol Apple named feeds it to PBKDF2. */
|
|
75
|
+
const preparePassword = (password, protocol) => {
|
|
76
|
+
const digest = node_crypto.default.createHash("sha256").update(password).digest();
|
|
77
|
+
if (protocol === "s2k") return digest;
|
|
78
|
+
if (protocol === "s2k_fo") return Buffer.from(digest.toString("hex"));
|
|
79
|
+
throw new Error(`Apple asked for the sign-in protocol ${protocol}.`);
|
|
80
|
+
};
|
|
81
|
+
/** A new SRP client; `secret` is the private value a, random unless set. */
|
|
82
|
+
const createSrpClient = (secret = fromBytes(node_crypto.default.randomBytes(256))) => {
|
|
83
|
+
const A = modPow(G, secret, N);
|
|
84
|
+
const aHex = toHex(A);
|
|
85
|
+
return {
|
|
86
|
+
a: Buffer.from(aHex, "hex").toString("base64"),
|
|
87
|
+
proof: ({ accountName, password, protocol, iterations, salt, b }) => {
|
|
88
|
+
const saltBytes = Buffer.from(salt, "base64");
|
|
89
|
+
const saltHex = saltBytes.toString("hex");
|
|
90
|
+
const bHex = Buffer.from(b, "base64").toString("hex");
|
|
91
|
+
const B = BigInt(`0x${bHex || "0"}`);
|
|
92
|
+
if (B % N === 0n) throw new Error("Apple answered an invalid SRP value.");
|
|
93
|
+
const derived = node_crypto.default.pbkdf2Sync(preparePassword(password, protocol), saltBytes, iterations, 32, "sha256");
|
|
94
|
+
const x = BigInt(`0x${sha256Hex(saltHex + sha256Hex(`3a${derived.toString("hex")}`))}`);
|
|
95
|
+
const k = paddedHash(toHex(N), toHex(G));
|
|
96
|
+
const u = paddedHash(aHex, bHex);
|
|
97
|
+
if (u === 0n) throw new Error("Apple answered an invalid SRP value.");
|
|
98
|
+
const S = modPow(B - k * modPow(G, x, N) % N, secret + u * x, N);
|
|
99
|
+
const K = sha256Hex(toHex(S));
|
|
100
|
+
const m1 = sha256Hex(toHex(paddedHash(toHex(N)) ^ paddedHash(toHex(G))) + node_crypto.default.createHash("sha256").update(accountName).digest("hex") + saltHex + aHex + bHex + K);
|
|
101
|
+
const m2 = toHex(BigInt(`0x${sha256Hex(aHex + m1 + K)}`));
|
|
102
|
+
return {
|
|
103
|
+
m1: Buffer.from(m1, "hex").toString("base64"),
|
|
104
|
+
m2: Buffer.from(m2, "hex").toString("base64")
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
const hasLeadingZeroBits = (digest, bits) => {
|
|
110
|
+
const full = Math.floor(bits / 8);
|
|
111
|
+
for (let index = 0; index < full; index++) if (digest[index] !== 0) return false;
|
|
112
|
+
const rest = bits % 8;
|
|
113
|
+
return rest === 0 || (digest[full] & 255 << 8 - rest & 255) === 0;
|
|
114
|
+
};
|
|
54
115
|
/**
|
|
55
|
-
* The
|
|
56
|
-
*
|
|
57
|
-
*
|
|
116
|
+
* The hashcash stamp Apple's sign-in asks for in `X-Apple-HC`: the first
|
|
117
|
+
* `1:<bits>:<date>:<challenge>::<counter>` whose SHA-1 starts with `bits`
|
|
118
|
+
* zero bits.
|
|
58
119
|
*/
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
|
|
120
|
+
const makeHashcash = (bits, challenge, now = /* @__PURE__ */ new Date()) => {
|
|
121
|
+
const date = now.toISOString().replace(/\.\d+Z$/u, "").replaceAll(/[-:T]/gu, "");
|
|
122
|
+
for (let counter = 0;; counter++) {
|
|
123
|
+
const stamp = `1:${bits}:${date}:${challenge}::${counter}`;
|
|
124
|
+
const digest = node_crypto.default.createHash("sha1").update(stamp).digest();
|
|
125
|
+
if (hasLeadingZeroBits(digest, bits)) return stamp;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/asc/appleSignIn.ts
|
|
130
|
+
/**
|
|
131
|
+
* Signing in to App Store Connect with an Apple Account, as its web page
|
|
132
|
+
* does: Apple's SRP sign-in at idmsa.apple.com, its two-factor check, then
|
|
133
|
+
* App Store Connect's session. The endpoints and their headers follow asc,
|
|
134
|
+
* the open-source App Store Connect CLI
|
|
135
|
+
* (https://github.com/rorkai/App-Store-Connect-CLI, MIT), 5.16.1, whose
|
|
136
|
+
* internal/web/auth.go has signed in this way against Apple.
|
|
137
|
+
*/
|
|
138
|
+
const AUTH_URL = "https://idmsa.apple.com/appleauth/auth";
|
|
139
|
+
const ASC_URL = "https://appstoreconnect.apple.com";
|
|
140
|
+
const SESSION_URL = `${ASC_URL}/olympus/v1/session`;
|
|
141
|
+
/**
|
|
142
|
+
* The public widget key App Store Connect's sign-in page ships, used when
|
|
143
|
+
* neither place Apple publishes the current one answers. Not a credential.
|
|
144
|
+
*/
|
|
145
|
+
const ASC_WIDGET_KEY = "e0b80c3bf78523bfe80974d320935bfa30add02e1bff88ec2166c6bd5a706c42";
|
|
146
|
+
/** Signing in, or a request in the signed-in session, failed. */
|
|
147
|
+
var AppleSignInError = class extends Error {
|
|
148
|
+
status;
|
|
149
|
+
/** The response body, for messages that depend on what Apple said. */
|
|
150
|
+
body;
|
|
71
151
|
constructor(message, options) {
|
|
72
|
-
super(message, options);
|
|
73
|
-
this.name = "
|
|
152
|
+
super(message, options?.cause ? { cause: options.cause } : void 0);
|
|
153
|
+
this.name = "AppleSignInError";
|
|
154
|
+
this.status = options?.status;
|
|
155
|
+
this.body = options?.body;
|
|
74
156
|
}
|
|
75
157
|
};
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
158
|
+
/**
|
|
159
|
+
* A cookie jar for the sign-in's requests, as a browser keeps one: `fetch`
|
|
160
|
+
* keeps none, and Apple's session is its cookies.
|
|
161
|
+
*/
|
|
162
|
+
const createCookieJar = () => {
|
|
163
|
+
let cookies = [];
|
|
164
|
+
const live = () => {
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
cookies = cookies.filter((cookie) => cookie.expires === void 0 || cookie.expires > now);
|
|
167
|
+
return cookies;
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
store(url, response) {
|
|
171
|
+
for (const header of response.headers.getSetCookie()) {
|
|
172
|
+
const [pair = "", ...attributes] = header.split(";");
|
|
173
|
+
const separator = pair.indexOf("=");
|
|
174
|
+
if (separator <= 0) continue;
|
|
175
|
+
const name = pair.slice(0, separator).trim();
|
|
176
|
+
const value = pair.slice(separator + 1).trim();
|
|
177
|
+
let domain = url.hostname;
|
|
178
|
+
let hostOnly = true;
|
|
179
|
+
let path = "/";
|
|
180
|
+
let expires;
|
|
181
|
+
let maxAge;
|
|
182
|
+
for (const attribute of attributes) {
|
|
183
|
+
const index = attribute.indexOf("=");
|
|
184
|
+
const key = (index === -1 ? attribute : attribute.slice(0, index)).trim().toLowerCase();
|
|
185
|
+
const raw = index === -1 ? "" : attribute.slice(index + 1).trim();
|
|
186
|
+
if (key === "domain" && raw) {
|
|
187
|
+
const candidate = raw.replace(/^\./u, "").toLowerCase();
|
|
188
|
+
if (url.hostname !== candidate && !url.hostname.endsWith(`.${candidate}`)) {
|
|
189
|
+
domain = "";
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
domain = candidate;
|
|
193
|
+
hostOnly = false;
|
|
194
|
+
} else if (key === "path" && raw.startsWith("/")) path = raw;
|
|
195
|
+
else if (key === "expires") {
|
|
196
|
+
const time = Date.parse(raw);
|
|
197
|
+
if (!Number.isNaN(time)) expires = time;
|
|
198
|
+
} else if (key === "max-age" && /^-?\d+$/u.test(raw)) maxAge = Number(raw);
|
|
199
|
+
}
|
|
200
|
+
if (!domain) continue;
|
|
201
|
+
if (maxAge !== void 0) expires = Date.now() + maxAge * 1e3;
|
|
202
|
+
cookies = cookies.filter((cookie) => !(cookie.name === name && cookie.domain === domain && cookie.path === path));
|
|
203
|
+
if (expires === void 0 || expires > Date.now()) cookies.push({
|
|
204
|
+
name,
|
|
205
|
+
value,
|
|
206
|
+
domain,
|
|
207
|
+
hostOnly,
|
|
208
|
+
path,
|
|
209
|
+
expires
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
header(url) {
|
|
214
|
+
const matching = live().filter((cookie) => (cookie.hostOnly ? url.hostname === cookie.domain : url.hostname === cookie.domain || url.hostname.endsWith(`.${cookie.domain}`)) && (url.pathname === cookie.path || url.pathname.startsWith(cookie.path.endsWith("/") ? cookie.path : `${cookie.path}/`)));
|
|
215
|
+
return matching.length === 0 ? void 0 : matching.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
|
216
|
+
},
|
|
217
|
+
clear() {
|
|
218
|
+
cookies = [];
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
const REDIRECTS = /* @__PURE__ */ new Set([
|
|
223
|
+
301,
|
|
224
|
+
302,
|
|
225
|
+
303,
|
|
226
|
+
307,
|
|
227
|
+
308
|
|
228
|
+
]);
|
|
229
|
+
/** `fetch` with the jar's cookies, following redirects itself to keep them. */
|
|
230
|
+
const createClient = (fetch) => {
|
|
231
|
+
const jar = createCookieJar();
|
|
232
|
+
const request = async (url, init = {}) => {
|
|
233
|
+
let current = new URL(url);
|
|
234
|
+
let method = init.method ?? "GET";
|
|
235
|
+
let body = init.body;
|
|
236
|
+
for (let hop = 0; hop < 10; hop++) {
|
|
237
|
+
const headers = new Headers(init.headers);
|
|
238
|
+
const cookie = jar.header(current);
|
|
239
|
+
if (cookie) headers.set("Cookie", cookie);
|
|
240
|
+
const response = await fetch(current, {
|
|
241
|
+
method,
|
|
242
|
+
headers,
|
|
243
|
+
...body === void 0 ? {} : { body },
|
|
244
|
+
redirect: "manual"
|
|
245
|
+
});
|
|
246
|
+
jar.store(current, response);
|
|
247
|
+
const location = response.headers.get("location");
|
|
248
|
+
if (!REDIRECTS.has(response.status) || !location) return response;
|
|
249
|
+
await response.text();
|
|
250
|
+
current = new URL(location, current);
|
|
251
|
+
if (response.status === 303 || response.status !== 307 && response.status !== 308 && method === "POST") {
|
|
252
|
+
method = "GET";
|
|
253
|
+
body = void 0;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
throw new AppleSignInError(`${url} redirected too many times.`);
|
|
257
|
+
};
|
|
258
|
+
return {
|
|
259
|
+
request,
|
|
260
|
+
clear: jar.clear
|
|
261
|
+
};
|
|
79
262
|
};
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
263
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
264
|
+
const text$1 = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
265
|
+
const parseJson = (body) => {
|
|
266
|
+
try {
|
|
267
|
+
return JSON.parse(body);
|
|
268
|
+
} catch {
|
|
269
|
+
return;
|
|
84
270
|
}
|
|
85
|
-
return true;
|
|
86
271
|
};
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
272
|
+
/** What Apple's sign-in said went wrong, from its `serviceErrors`. */
|
|
273
|
+
const serviceErrors = (body) => {
|
|
274
|
+
const parsed = parseJson(body);
|
|
275
|
+
const errors = isRecord$1(parsed) && Array.isArray(parsed.serviceErrors) ? parsed.serviceErrors.filter(isRecord$1) : [];
|
|
276
|
+
return {
|
|
277
|
+
codes: errors.map((error) => text$1(error.code)).filter(Boolean),
|
|
278
|
+
message: errors.map((error) => text$1(error.message)).find(Boolean)
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
const failure = (step, response, body) => new AppleSignInError(`${step} failed (HTTP ${response.status}).${serviceErrors(body).message ? `\n${serviceErrors(body).message}` : ""}`, {
|
|
282
|
+
status: response.status,
|
|
283
|
+
body
|
|
90
284
|
});
|
|
91
|
-
/** Where a downloaded asc is kept, per user, so every project shares it. */
|
|
92
|
-
const defaultAscCacheDir = () => node_path.default.join(node_os.default.homedir(), "Library", "Caches", "hot-updater");
|
|
93
285
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* one: TestFlight builds need Xcode.
|
|
286
|
+
* The widget key Apple's sign-in wants: from the sign-out redirect App Store
|
|
287
|
+
* Connect answers without cookies, else its config, else the one its page
|
|
288
|
+
* ships.
|
|
98
289
|
*/
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
reject: false,
|
|
104
|
-
env: { ASC_TELEMETRY_DISABLED: "1" },
|
|
105
|
-
timeout: 15e3
|
|
290
|
+
const findWidgetKey = async (fetch, client) => {
|
|
291
|
+
const signOut = await fetch(`${ASC_URL}/logout`, {
|
|
292
|
+
method: "HEAD",
|
|
293
|
+
redirect: "manual"
|
|
106
294
|
}).catch(() => void 0);
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
295
|
+
const location = signOut?.headers.get("location");
|
|
296
|
+
if (signOut && REDIRECTS.has(signOut.status) && location) {
|
|
297
|
+
const url = (() => {
|
|
298
|
+
try {
|
|
299
|
+
return new URL(location);
|
|
300
|
+
} catch {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
})();
|
|
304
|
+
const keys = url?.searchParams.getAll("widgetKey") ?? [];
|
|
305
|
+
if (url?.protocol === "https:" && url.hostname === "idmsa.apple.com" && url.pathname === "/appleauth/signout" && keys.length === 1 && keys[0].trim()) return keys[0].trim();
|
|
306
|
+
}
|
|
307
|
+
const config = await client.request(`${ASC_URL}/olympus/v1/app/config?hostname=itunesconnect.apple.com`);
|
|
308
|
+
const body = await config.text();
|
|
309
|
+
if (config.status === 404) return ASC_WIDGET_KEY;
|
|
310
|
+
const parsed = parseJson(body);
|
|
311
|
+
const key = isRecord$1(parsed) ? text$1(parsed.authServiceKey) ?? text$1(parsed.serviceKey) : void 0;
|
|
312
|
+
if (!config.ok || !key) throw new AppleSignInError(`Couldn't load Apple's sign-in configuration (HTTP ${config.status}).`, { status: config.status });
|
|
313
|
+
return key;
|
|
314
|
+
};
|
|
315
|
+
const hashcash = async (client, widgetKey) => {
|
|
316
|
+
const response = await client.request(`${AUTH_URL}/signin?widgetKey=${encodeURIComponent(widgetKey)}`, { headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" } });
|
|
317
|
+
await response.text();
|
|
318
|
+
if (!response.ok) throw new AppleSignInError(`Loading Apple's sign-in page failed (HTTP ${response.status}).`, { status: response.status });
|
|
319
|
+
const bits = response.headers.get("x-apple-hc-bits")?.trim();
|
|
320
|
+
const challenge = response.headers.get("x-apple-hc-challenge")?.trim();
|
|
321
|
+
if (!bits || !challenge) return void 0;
|
|
322
|
+
if (!/^\d+$/u.test(bits) || Number(bits) > 32) throw new AppleSignInError(`Apple asked for a hashcash of ${bits} bits.`);
|
|
323
|
+
return makeHashcash(Number(bits), challenge);
|
|
324
|
+
};
|
|
325
|
+
const pendingHeaders = (pending) => ({
|
|
326
|
+
"X-Apple-ID-Session-Id": pending.sessionId,
|
|
327
|
+
"X-Apple-Widget-Key": pending.widgetKey,
|
|
328
|
+
scnt: pending.scnt,
|
|
329
|
+
Accept: "application/json"
|
|
330
|
+
});
|
|
331
|
+
/** Apple rotates `scnt` and the session ID; each request sends the latest. */
|
|
332
|
+
const track = (pending, response) => {
|
|
333
|
+
const scnt = response.headers.get("scnt")?.trim();
|
|
334
|
+
const sessionId = response.headers.get("x-apple-id-session-id")?.trim();
|
|
335
|
+
if (scnt) pending.scnt = scnt;
|
|
336
|
+
if (sessionId) pending.sessionId = sessionId;
|
|
337
|
+
};
|
|
338
|
+
const CANCELLED = "Signing in was cancelled.";
|
|
339
|
+
const twoFactor = async (client, pending, prompts) => {
|
|
340
|
+
const send = async (url, method, payload) => {
|
|
341
|
+
const response = await client.request(url, {
|
|
342
|
+
method,
|
|
343
|
+
headers: {
|
|
344
|
+
...pendingHeaders(pending),
|
|
345
|
+
...payload === void 0 ? {} : { "Content-Type": "application/json" }
|
|
346
|
+
},
|
|
347
|
+
...payload === void 0 ? {} : { body: JSON.stringify(payload) }
|
|
348
|
+
});
|
|
349
|
+
track(pending, response);
|
|
350
|
+
return {
|
|
351
|
+
response,
|
|
352
|
+
body: await response.text()
|
|
353
|
+
};
|
|
112
354
|
};
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
355
|
+
const options = await send(AUTH_URL, "GET");
|
|
356
|
+
if (!options.response.ok) throw failure("Loading the two-factor options", options.response, options.body);
|
|
357
|
+
const parsed = parseJson(options.body);
|
|
358
|
+
const phone = (isRecord$1(parsed) && Array.isArray(parsed.trustedPhoneNumbers) ? parsed.trustedPhoneNumbers.filter(isRecord$1) : []).filter((phone) => typeof phone.id === "number")[0];
|
|
359
|
+
const noDevices = isRecord$1(parsed) && parsed.noTrustedDevices === true;
|
|
360
|
+
if (noDevices && !phone) throw new AppleSignInError("The Apple Account has no trusted device or phone number for two-factor codes.");
|
|
361
|
+
const phoneMode = text$1(phone?.pushMode) ?? "sms";
|
|
362
|
+
const destination = text$1(phone?.numberWithDialCode);
|
|
363
|
+
const securityCode = isRecord$1(parsed) && isRecord$1(parsed.securityCode) ? parsed.securityCode.length : void 0;
|
|
364
|
+
const length = typeof securityCode === "number" && securityCode > 0 ? securityCode : 6;
|
|
365
|
+
const textCode = async () => {
|
|
366
|
+
const requested = await send(`${AUTH_URL}/verify/phone`, "PUT", {
|
|
367
|
+
phoneNumber: { id: phone.id },
|
|
368
|
+
mode: phoneMode
|
|
369
|
+
});
|
|
370
|
+
if (!requested.response.ok) throw failure("Texting a two-factor code", requested.response, requested.body);
|
|
121
371
|
};
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
372
|
+
const verify = async (via, code) => {
|
|
373
|
+
const verified = via === "device" ? await send(`${AUTH_URL}/verify/trusteddevice/securitycode`, "POST", { securityCode: { code } }) : await send(`${AUTH_URL}/verify/phone/securitycode`, "POST", {
|
|
374
|
+
securityCode: { code },
|
|
375
|
+
phoneNumber: { id: phone.id },
|
|
376
|
+
mode: phoneMode
|
|
377
|
+
});
|
|
378
|
+
if (verified.response.ok) return true;
|
|
379
|
+
if ([
|
|
380
|
+
400,
|
|
381
|
+
401,
|
|
382
|
+
403
|
|
383
|
+
].includes(verified.response.status)) return false;
|
|
384
|
+
throw failure("Checking the two-factor code", verified.response, verified.body);
|
|
385
|
+
};
|
|
386
|
+
const ask = async (via, afterWrongCode) => {
|
|
387
|
+
const code = await prompts.twoFactorCode({
|
|
388
|
+
via,
|
|
389
|
+
destination,
|
|
390
|
+
textsOnWrongCode: via === "device" && phone !== void 0,
|
|
391
|
+
afterWrongCode,
|
|
392
|
+
length
|
|
393
|
+
});
|
|
394
|
+
if (code === void 0) throw new AppleSignInError(CANCELLED);
|
|
395
|
+
return code;
|
|
396
|
+
};
|
|
397
|
+
let accepted;
|
|
398
|
+
if (noDevices) {
|
|
399
|
+
await textCode();
|
|
400
|
+
accepted = await verify("sms", await ask("sms", false));
|
|
401
|
+
} else {
|
|
402
|
+
accepted = await verify("device", await ask("device", false));
|
|
403
|
+
if (!accepted && phone) {
|
|
404
|
+
await textCode();
|
|
405
|
+
accepted = await verify("sms", await ask("sms", true));
|
|
130
406
|
}
|
|
131
|
-
if (!response.ok || !response.body) throw new AscCliUnavailableError(`Couldn't download asc ${ASC_CLI_VERSION} from GitHub (HTTP ${response.status}).\n${INSTALL_HINT}`);
|
|
132
|
-
await node_fs_promises.default.writeFile(partial, Buffer.from(await response.arrayBuffer()), { mode: 448 });
|
|
133
|
-
const actual = await sha256Of(partial);
|
|
134
|
-
if (actual !== expected) throw new AscCliUnavailableError([
|
|
135
|
-
`The downloaded asc ${ASC_CLI_VERSION} failed its SHA-256 check; it was deleted.`,
|
|
136
|
-
`Expected ${expected}`,
|
|
137
|
-
`Got ${actual}`,
|
|
138
|
-
INSTALL_HINT
|
|
139
|
-
].join("\n"));
|
|
140
|
-
await node_fs_promises.default.chmod(partial, 493);
|
|
141
|
-
await node_fs_promises.default.rename(partial, binary);
|
|
142
|
-
} finally {
|
|
143
|
-
await node_fs_promises.default.rm(partial, { force: true });
|
|
144
407
|
}
|
|
408
|
+
if (!accepted) throw new AppleSignInError("Apple rejected the verification code.");
|
|
409
|
+
const trust = await send(`${AUTH_URL}/2sv/trust`, "GET");
|
|
410
|
+
if (!trust.response.ok) throw failure("Finishing the two-factor check", trust.response, trust.body);
|
|
411
|
+
};
|
|
412
|
+
const parseProvider = (value) => {
|
|
413
|
+
if (!isRecord$1(value) || typeof value.providerId !== "number") return void 0;
|
|
414
|
+
const publicProviderId = text$1(value.publicProviderId);
|
|
415
|
+
if (!publicProviderId) return void 0;
|
|
145
416
|
return {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
417
|
+
providerId: value.providerId,
|
|
418
|
+
publicProviderId,
|
|
419
|
+
name: text$1(value.name)
|
|
149
420
|
};
|
|
150
421
|
};
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
422
|
+
const readSession = async (client) => {
|
|
423
|
+
const response = await client.request(SESSION_URL, { headers: { Accept: "application/json" } });
|
|
424
|
+
const body = await response.text();
|
|
425
|
+
if (!response.ok) throw new AppleSignInError(response.status === 401 || response.status === 403 ? "App Store Connect didn't accept the sign-in. Check that the Apple Account is in an Apple Developer team." : `Loading the App Store Connect session failed (HTTP ${response.status}).`, {
|
|
426
|
+
status: response.status,
|
|
427
|
+
body
|
|
428
|
+
});
|
|
429
|
+
const parsed = parseJson(body);
|
|
430
|
+
const provider = isRecord$1(parsed) ? parseProvider(parsed.provider) : void 0;
|
|
431
|
+
if (!isRecord$1(parsed) || !provider) throw new AppleSignInError("App Store Connect's session names no team for the Apple Account.");
|
|
432
|
+
const providers = (Array.isArray(parsed.availableProviders) ? parsed.availableProviders : []).map(parseProvider).filter((value) => value !== void 0);
|
|
433
|
+
return {
|
|
434
|
+
provider,
|
|
435
|
+
providers: providers.some((value) => value.providerId === provider.providerId) ? providers : [provider, ...providers]
|
|
436
|
+
};
|
|
161
437
|
};
|
|
162
|
-
const TERMINATION_SIGNALS$3 = [
|
|
163
|
-
"SIGHUP",
|
|
164
|
-
"SIGINT",
|
|
165
|
-
"SIGTERM"
|
|
166
|
-
];
|
|
167
438
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
439
|
+
* Signs in to App Store Connect with `appleId` and `password`, asking
|
|
440
|
+
* `prompts` for the two-factor code. The session lives only in the returned
|
|
441
|
+
* object's memory.
|
|
171
442
|
*/
|
|
172
|
-
const
|
|
173
|
-
const
|
|
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
|
-
|
|
443
|
+
const signInToAppStoreConnect = async ({ appleId, password, prompts, fetch = globalThis.fetch }) => {
|
|
444
|
+
const client = createClient(fetch);
|
|
445
|
+
try {
|
|
446
|
+
const widgetKey = await findWidgetKey(fetch, client);
|
|
447
|
+
const json = {
|
|
448
|
+
"Content-Type": "application/json",
|
|
449
|
+
"X-Apple-Widget-Key": widgetKey,
|
|
450
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
451
|
+
Accept: "application/json, text/javascript"
|
|
452
|
+
};
|
|
453
|
+
const srp = createSrpClient();
|
|
454
|
+
const init = await client.request(`${AUTH_URL}/signin/init`, {
|
|
455
|
+
method: "POST",
|
|
456
|
+
headers: json,
|
|
457
|
+
body: JSON.stringify({
|
|
458
|
+
accountName: appleId,
|
|
459
|
+
protocols: ["s2k", "s2k_fo"],
|
|
460
|
+
a: srp.a
|
|
461
|
+
})
|
|
462
|
+
});
|
|
463
|
+
const initBody = await init.text();
|
|
464
|
+
if (!init.ok) throw failure("Starting the sign-in", init, initBody);
|
|
465
|
+
const started = parseJson(initBody);
|
|
466
|
+
if (!isRecord$1(started) || typeof started.iteration !== "number" || !text$1(started.salt) || !text$1(started.b) || !text$1(started.protocol)) throw new AppleSignInError("Apple's sign-in answered no SRP challenge.");
|
|
467
|
+
const { m1, m2 } = srp.proof({
|
|
468
|
+
accountName: appleId,
|
|
469
|
+
password,
|
|
470
|
+
protocol: text$1(started.protocol),
|
|
471
|
+
iterations: started.iteration,
|
|
472
|
+
salt: text$1(started.salt),
|
|
473
|
+
b: text$1(started.b)
|
|
474
|
+
});
|
|
475
|
+
const stamp = await hashcash(client, widgetKey);
|
|
476
|
+
const complete = await client.request(`${AUTH_URL}/signin/complete?isRememberMeEnabled=false`, {
|
|
477
|
+
method: "POST",
|
|
478
|
+
headers: {
|
|
479
|
+
...json,
|
|
480
|
+
...stamp ? { "X-Apple-HC": stamp } : {}
|
|
481
|
+
},
|
|
482
|
+
body: JSON.stringify({
|
|
483
|
+
accountName: appleId,
|
|
484
|
+
rememberMe: false,
|
|
485
|
+
m1,
|
|
486
|
+
m2,
|
|
487
|
+
c: started.c
|
|
488
|
+
})
|
|
489
|
+
});
|
|
490
|
+
const completeBody = await complete.text();
|
|
491
|
+
if (complete.status === 409) {
|
|
492
|
+
const sessionId = complete.headers.get("x-apple-id-session-id")?.trim();
|
|
493
|
+
const scnt = complete.headers.get("scnt")?.trim();
|
|
494
|
+
if (!sessionId || !scnt) throw new AppleSignInError("Apple asked for a two-factor code without a sign-in session.");
|
|
495
|
+
await twoFactor(client, {
|
|
496
|
+
widgetKey,
|
|
497
|
+
sessionId,
|
|
498
|
+
scnt
|
|
499
|
+
}, prompts);
|
|
500
|
+
} else if (!complete.ok) {
|
|
501
|
+
const { codes } = serviceErrors(completeBody);
|
|
502
|
+
const authType = (() => {
|
|
503
|
+
const parsed = parseJson(completeBody);
|
|
504
|
+
return isRecord$1(parsed) ? text$1(parsed.authType) : void 0;
|
|
205
505
|
})();
|
|
206
|
-
|
|
506
|
+
if (complete.status === 401 && codes.includes("-20101")) throw new AppleSignInError("Apple rejected the email or password.", { status: 401 });
|
|
507
|
+
if (complete.status === 412 && authType && [
|
|
508
|
+
"sa",
|
|
509
|
+
"hsa",
|
|
510
|
+
"non-sa",
|
|
511
|
+
"hsa2"
|
|
512
|
+
].includes(authType)) throw new AppleSignInError("The Apple Account has a pending prompt. Sign in at https://appstoreconnect.apple.com once, then run this again.", { status: 412 });
|
|
513
|
+
throw failure("Signing in", complete, completeBody);
|
|
207
514
|
}
|
|
208
|
-
|
|
515
|
+
let { provider, providers } = await readSession(client);
|
|
516
|
+
return {
|
|
517
|
+
appleId,
|
|
518
|
+
get provider() {
|
|
519
|
+
return provider;
|
|
520
|
+
},
|
|
521
|
+
get providers() {
|
|
522
|
+
return providers;
|
|
523
|
+
},
|
|
524
|
+
selectProvider: async (publicProviderId) => {
|
|
525
|
+
if (provider.publicProviderId === publicProviderId) return provider;
|
|
526
|
+
const selected = providers.find((value) => value.publicProviderId === publicProviderId);
|
|
527
|
+
if (!selected) throw new AppleSignInError(`The Apple Account is in no team ${publicProviderId}.`);
|
|
528
|
+
const response = await client.request(SESSION_URL, {
|
|
529
|
+
method: "POST",
|
|
530
|
+
headers: {
|
|
531
|
+
"Content-Type": "application/json",
|
|
532
|
+
"X-Requested-With": "olympus-ui"
|
|
533
|
+
},
|
|
534
|
+
body: JSON.stringify({ provider: { providerId: selected.providerId } })
|
|
535
|
+
});
|
|
536
|
+
const body = await response.text();
|
|
537
|
+
if (!response.ok) throw new AppleSignInError(`Switching to the team ${selected.name ?? publicProviderId} failed (HTTP ${response.status}).`, {
|
|
538
|
+
status: response.status,
|
|
539
|
+
body
|
|
540
|
+
});
|
|
541
|
+
({provider, providers} = await readSession(client));
|
|
542
|
+
if (provider.providerId !== selected.providerId) throw new AppleSignInError(`App Store Connect stayed in the team ${provider.name ?? provider.publicProviderId}.`);
|
|
543
|
+
return provider;
|
|
544
|
+
},
|
|
545
|
+
request: client.request,
|
|
546
|
+
clear: client.clear
|
|
547
|
+
};
|
|
548
|
+
} catch (error) {
|
|
549
|
+
client.clear();
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
209
552
|
};
|
|
553
|
+
//#endregion
|
|
554
|
+
//#region src/asc/ascKeyIssuer.ts
|
|
555
|
+
const IRIS_URL = "https://appstoreconnect.apple.com/iris/v1";
|
|
210
556
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* stored, the keychain is bypassed, and telemetry is off.
|
|
557
|
+
* The headers App Store Connect's Integrations page sends its API with. A
|
|
558
|
+
* function: `ascApiKey` imports this module, so its constants aren't set
|
|
559
|
+
* yet when this module loads.
|
|
215
560
|
*/
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
XDG_CACHE_HOME: node_path.default.join(root, "home", ".cache"),
|
|
224
|
-
ASC_CONFIG_PATH: node_path.default.join(root, "home", "config.json"),
|
|
225
|
-
ASC_WEB_SESSION_CACHE_DIR: node_path.default.join(root, "session"),
|
|
226
|
-
ASC_WEB_SESSION_CACHE_BACKEND: "file",
|
|
227
|
-
ASC_WEB_DONT_STORE_PASSWORD: "1",
|
|
228
|
-
ASC_BYPASS_KEYCHAIN: "1",
|
|
229
|
-
ASC_TELEMETRY_DISABLED: "1",
|
|
230
|
-
ASC_SPINNER_DISABLED: "1"
|
|
231
|
-
};
|
|
232
|
-
};
|
|
561
|
+
const irisHeaders = () => ({
|
|
562
|
+
Accept: "application/vnd.api+json, application/json, text/csv",
|
|
563
|
+
"Content-Type": "application/json",
|
|
564
|
+
"X-CSRF-ITC": "[asc-ui]",
|
|
565
|
+
Origin: "https://appstoreconnect.apple.com",
|
|
566
|
+
Referer: ASC_INTEGRATIONS_URL
|
|
567
|
+
});
|
|
233
568
|
const answer = (value) => _hot_updater_cli_tools.p.isCancel(value) ? void 0 : value;
|
|
569
|
+
/** One line: where the code went, and where a wrong one sends the next. */
|
|
570
|
+
const twoFactorMessage = (request) => {
|
|
571
|
+
const phone = request.destination ?? "your trusted phone";
|
|
572
|
+
if (request.via === "sms") return `${request.afterWrongCode ? "Wrong code. " : ""}Code texted to ${phone}`;
|
|
573
|
+
return request.textsOnWrongCode ? `Code on your Apple device (wrong code texts ${phone})` : "Code on your Apple device";
|
|
574
|
+
};
|
|
234
575
|
/** Hot Updater's own prompts, so the sign-in looks like the rest of the CLI. */
|
|
235
576
|
const cliPrompts = {
|
|
236
577
|
password: async (appleId) => answer(await _hot_updater_cli_tools.p.password({
|
|
@@ -238,65 +579,53 @@ const cliPrompts = {
|
|
|
238
579
|
mask: "•",
|
|
239
580
|
validate: (value) => value ? void 0 : "Enter the password."
|
|
240
581
|
})),
|
|
241
|
-
twoFactorCode: async (
|
|
242
|
-
message:
|
|
243
|
-
validate: (value) =>
|
|
582
|
+
twoFactorCode: async (request) => answer(await _hot_updater_cli_tools.p.text({
|
|
583
|
+
message: twoFactorMessage(request),
|
|
584
|
+
validate: (value) => new RegExp(`^\\d{${request.length}}$`, "u").test(value?.trim() ?? "") ? void 0 : `Enter the ${request.length}-digit code.`
|
|
244
585
|
}))?.trim()
|
|
245
586
|
};
|
|
246
|
-
/** `value` as one word for `sh`. */
|
|
247
|
-
const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
248
|
-
/**
|
|
249
|
-
* A two-factor code command for asc that asks `prompts` in this process:
|
|
250
|
-
* asc runs it when Apple wants a code, and it prints what the developer
|
|
251
|
-
* typed, which it gets over a socket in the 0700 work directory. A
|
|
252
|
-
* cancelled prompt makes it print nothing and fail, which stops the sign-in.
|
|
253
|
-
*/
|
|
254
|
-
const listenForTwoFactorCodes = async (root, prompts) => {
|
|
255
|
-
const socketPath = node_path.default.join(root, "2fa.sock");
|
|
256
|
-
const scriptPath = node_path.default.join(root, "two-factor-code.cjs");
|
|
257
|
-
await node_fs_promises.default.writeFile(scriptPath, [
|
|
258
|
-
"const socket = require(\"node:net\").connect(" + JSON.stringify(socketPath) + ");",
|
|
259
|
-
"let code = \"\";",
|
|
260
|
-
"socket.on(\"data\", (chunk) => { code += chunk; });",
|
|
261
|
-
"socket.on(\"end\", () => {",
|
|
262
|
-
" code = code.trim();",
|
|
263
|
-
" if (!code) process.exit(1);",
|
|
264
|
-
" process.stdout.write(code + \"\\n\");",
|
|
265
|
-
"});",
|
|
266
|
-
"socket.on(\"error\", () => process.exit(1));",
|
|
267
|
-
""
|
|
268
|
-
].join("\n"), { mode: 384 });
|
|
269
|
-
let attempts = 0;
|
|
270
|
-
let cancelled = false;
|
|
271
|
-
const server = node_net.default.createServer((socket) => {
|
|
272
|
-
attempts += 1;
|
|
273
|
-
prompts.twoFactorCode(attempts).then((code) => {
|
|
274
|
-
if (!code) cancelled = true;
|
|
275
|
-
socket.end(code ? `${code}\n` : "");
|
|
276
|
-
}, () => {
|
|
277
|
-
cancelled = true;
|
|
278
|
-
socket.destroy();
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
await new Promise((resolve, reject) => {
|
|
282
|
-
server.once("error", reject);
|
|
283
|
-
server.listen(socketPath, resolve);
|
|
284
|
-
});
|
|
285
|
-
return {
|
|
286
|
-
command: `${shellQuote(process.execPath)} ${shellQuote(scriptPath)}`,
|
|
287
|
-
cancelled: () => cancelled,
|
|
288
|
-
close: () => new Promise((resolve) => server.close(() => resolve()))
|
|
289
|
-
};
|
|
290
|
-
};
|
|
291
587
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
292
588
|
const text = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
589
|
+
const toTeam = (appleId, provider) => ({
|
|
590
|
+
appleId,
|
|
591
|
+
teamId: provider.publicProviderId,
|
|
592
|
+
...provider.name ? { name: provider.name } : {}
|
|
593
|
+
});
|
|
594
|
+
/** What App Store Connect's API said went wrong, from its JSON:API errors. */
|
|
595
|
+
const irisDetail = (body) => {
|
|
596
|
+
try {
|
|
597
|
+
const parsed = JSON.parse(body);
|
|
598
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.errors)) return void 0;
|
|
599
|
+
return parsed.errors.filter(isRecord).map((error) => text(error.detail) ?? text(error.title)).find(Boolean);
|
|
600
|
+
} catch {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
/** What to do about a key App Store Connect would not create. */
|
|
605
|
+
const createFailureHint = (status, body) => {
|
|
606
|
+
if (/agreement/iu.test(body)) return "The Account Holder must accept the new Apple Developer Program License\nAgreement at https://developer.apple.com/account, then run this again.";
|
|
607
|
+
if (/limit|maximum/iu.test(body)) return `The team has the most API keys App Store Connect allows. Revoke one at:\n${ASC_INTEGRATIONS_URL}`;
|
|
608
|
+
if (status === 403 || /account holder|admin|permission/iu.test(body)) return "Creating an API key needs the Account Holder or Admin role in the team.";
|
|
609
|
+
};
|
|
610
|
+
const irisRequest = async (session, step, method, path, payload) => {
|
|
611
|
+
const response = await session.request(`${IRIS_URL}${path}`, {
|
|
612
|
+
method,
|
|
613
|
+
headers: irisHeaders(),
|
|
614
|
+
...payload === void 0 ? {} : { body: JSON.stringify(payload) }
|
|
615
|
+
});
|
|
616
|
+
const body = await response.text();
|
|
617
|
+
if (!response.ok) {
|
|
618
|
+
const detail = irisDetail(body);
|
|
619
|
+
throw new AppleSignInError(`${step} failed (HTTP ${response.status}).${detail ? `\n${detail}` : ""}`, {
|
|
620
|
+
status: response.status,
|
|
621
|
+
body
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
const parsed = JSON.parse(body);
|
|
626
|
+
if (isRecord(parsed) && isRecord(parsed.data)) return parsed.data;
|
|
627
|
+
} catch {}
|
|
628
|
+
throw new AppleSignInError(`${step} answered no API key.`, { status: response.status });
|
|
300
629
|
};
|
|
301
630
|
/** A P-256 private key, as App Store Connect issues one, or a thrown error. */
|
|
302
631
|
const assertP8 = (p8) => {
|
|
@@ -304,144 +633,108 @@ const assertP8 = (p8) => {
|
|
|
304
633
|
try {
|
|
305
634
|
key = node_crypto.default.createPrivateKey(p8);
|
|
306
635
|
} catch {
|
|
307
|
-
throw new Error("
|
|
636
|
+
throw new Error("App Store Connect sent a .p8 that is not a private key.");
|
|
308
637
|
}
|
|
309
|
-
if (key.asymmetricKeyType !== "ec") throw new Error("
|
|
638
|
+
if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new Error("App Store Connect sent a .p8 that is not a P-256 private key.");
|
|
639
|
+
};
|
|
640
|
+
/** The one-time `.p8`; a key App Store Connect hasn't settled yet is retried. */
|
|
641
|
+
const downloadP8 = async (session, keyId, wait) => {
|
|
642
|
+
for (let attempt = 1;; attempt++) try {
|
|
643
|
+
const data = await irisRequest(session, "Downloading the key's .p8", "GET", `/apiKeys/${encodeURIComponent(keyId)}?${new URLSearchParams({ "fields[apiKeys]": "privateKey" })}`);
|
|
644
|
+
const encoded = isRecord(data.attributes) ? text(data.attributes.privateKey) : void 0;
|
|
645
|
+
if (text(data.id) !== keyId || !encoded) throw new Error("App Store Connect sent no .p8 for the key.");
|
|
646
|
+
return Buffer.from(encoded, "base64").toString("utf-8").trim();
|
|
647
|
+
} catch (error) {
|
|
648
|
+
if (!(error instanceof AppleSignInError && error.status !== void 0 && (error.status === 404 || error.status === 409 || error.status === 429 || error.status >= 500)) || attempt === 3) throw error;
|
|
649
|
+
await wait(attempt * 1e3);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
const issuerOf = (data) => {
|
|
653
|
+
const attributes = isRecord(data.attributes) ? data.attributes : {};
|
|
654
|
+
const relationships = isRecord(data.relationships) ? data.relationships : {};
|
|
655
|
+
const related = isRecord(relationships.provider) ? relationships.provider.data : void 0;
|
|
656
|
+
return (isRecord(attributes.provider) ? text(attributes.provider.id) : void 0) ?? (isRecord(related) ? text(related.id) : void 0);
|
|
310
657
|
};
|
|
311
658
|
/**
|
|
312
|
-
* `IssueAscApiKey`
|
|
313
|
-
* (
|
|
659
|
+
* `IssueAscApiKey` by signing in as App Store Connect's web page does
|
|
660
|
+
* (`signInToAppStoreConnect`) and creating the key with the API its
|
|
661
|
+
* Integrations page uses:
|
|
314
662
|
*
|
|
315
|
-
* 1.
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* 3.
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* 4. `revoke` runs `asc web api-keys revoke` in the same session.
|
|
663
|
+
* 1. The password typed into Hot Updater's prompt goes only to Apple's SRP
|
|
664
|
+
* sign-in, which sends a proof of it, never the password; the two-factor
|
|
665
|
+
* code goes only to Apple's check. Neither is kept or printed.
|
|
666
|
+
* 2. `chooseTeam` sees the teams the Apple Account is in, unless `teamId`
|
|
667
|
+
* is set.
|
|
668
|
+
* 3. The key is created, its one-time `.p8` downloaded and checked; a key
|
|
669
|
+
* that fails the check is revoked at once.
|
|
670
|
+
* 4. `revoke` deactivates the key in the same session.
|
|
324
671
|
*
|
|
325
|
-
*
|
|
326
|
-
* temporary 0700 directory, which `dispose` deletes, as does the process
|
|
327
|
-
* exiting first.
|
|
672
|
+
* The session is cookies in this process's memory, which `dispose` drops.
|
|
328
673
|
*
|
|
329
|
-
* @param
|
|
330
|
-
* @param env The environment asc's own is derived from.
|
|
674
|
+
* @param fetch Where requests go, Apple unless set.
|
|
331
675
|
* @param prompts Where the password and two-factor codes are asked.
|
|
676
|
+
* @param wait How a retried download waits.
|
|
332
677
|
*/
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
_hot_updater_cli_tools.p.log.info("Signing in
|
|
678
|
+
const createAscKeyIssuer = ({ fetch = globalThis.fetch, prompts = cliPrompts, wait = node_timers_promises.setTimeout } = {}) => async ({ name, role, appleId, teamId, chooseTeam }) => {
|
|
679
|
+
if (!appleId) throw new AppleSignInError("Signing in needs the Apple Account's email.");
|
|
680
|
+
_hot_updater_cli_tools.p.log.info("Signing in to App Store Connect. Only the issued key is kept.");
|
|
336
681
|
const password = await prompts.password(appleId);
|
|
337
|
-
if (password === void 0) throw new
|
|
682
|
+
if (password === void 0) throw new AppleSignInError("Signing in was cancelled.");
|
|
338
683
|
(0, _hot_updater_cli_tools.registerSecretValue)(password);
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
stdout: "pipe",
|
|
350
|
-
stderr: "pipe",
|
|
351
|
-
reject: false
|
|
352
|
-
});
|
|
353
|
-
const stderr = String(result.stderr ?? "");
|
|
354
|
-
if (result.exitCode !== 0 || result.failed) throw new AscCommandError(`${step} failed: ${lastLine(stderr) ?? (result.signal ? `stopped by ${result.signal}` : void 0) ?? `exit code ${result.exitCode}`}`, result.exitCode, { cause: { stderr } });
|
|
355
|
-
let parsed;
|
|
356
|
-
try {
|
|
357
|
-
parsed = JSON.parse(String(result.stdout));
|
|
358
|
-
} catch {
|
|
359
|
-
parsed = void 0;
|
|
360
|
-
}
|
|
361
|
-
if (!isRecord(parsed)) throw new AscCommandError(`${step} printed no JSON this version of Hot Updater reads.`, result.exitCode);
|
|
362
|
-
return parsed;
|
|
684
|
+
const session = await signInToAppStoreConnect({
|
|
685
|
+
appleId,
|
|
686
|
+
password,
|
|
687
|
+
prompts,
|
|
688
|
+
fetch
|
|
689
|
+
});
|
|
690
|
+
let disposed = false;
|
|
691
|
+
const dispose = async () => {
|
|
692
|
+
disposed = true;
|
|
693
|
+
session.clear();
|
|
363
694
|
};
|
|
364
695
|
try {
|
|
365
|
-
const
|
|
366
|
-
|
|
696
|
+
const teams = session.providers.map((provider) => toTeam(appleId, provider));
|
|
697
|
+
const chosen = teamId ?? await chooseTeam?.(teams, toTeam(appleId, session.provider));
|
|
698
|
+
const provider = chosen === void 0 ? session.provider : await session.selectProvider(chosen);
|
|
699
|
+
let data;
|
|
367
700
|
try {
|
|
368
|
-
|
|
369
|
-
"
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
], { ASC_WEB_PASSWORD: password });
|
|
701
|
+
data = await irisRequest(session, "Creating the App Store Connect API key", "POST", "/apiKeys", { data: {
|
|
702
|
+
type: "apiKeys",
|
|
703
|
+
attributes: {
|
|
704
|
+
nickname: name,
|
|
705
|
+
roles: [role],
|
|
706
|
+
allAppsVisible: true,
|
|
707
|
+
keyType: "PUBLIC_API"
|
|
708
|
+
}
|
|
709
|
+
} });
|
|
378
710
|
} catch (error) {
|
|
379
|
-
|
|
711
|
+
const hint = error instanceof AppleSignInError && error.status !== void 0 ? createFailureHint(error.status, error.body ?? "") : void 0;
|
|
712
|
+
if (hint) throw new AppleSignInError(`${error.message}\n${hint}`, {
|
|
713
|
+
...error.status === void 0 ? {} : { status: error.status },
|
|
714
|
+
cause: error
|
|
715
|
+
});
|
|
380
716
|
throw error;
|
|
381
|
-
} finally {
|
|
382
|
-
await twoFactor.close();
|
|
383
717
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
teamId: chosen
|
|
718
|
+
const keyId = text(data.id);
|
|
719
|
+
if (!keyId) throw new AppleSignInError("App Store Connect created a key without an ID.");
|
|
720
|
+
const revoke = async () => {
|
|
721
|
+
if (disposed) return false;
|
|
722
|
+
return irisRequest(session, "Revoking the key", "PATCH", `/apiKeys/${encodeURIComponent(keyId)}`, { data: {
|
|
723
|
+
id: keyId,
|
|
724
|
+
type: "apiKeys",
|
|
725
|
+
attributes: { isActive: false }
|
|
726
|
+
} }).then(() => true, () => false);
|
|
394
727
|
};
|
|
395
|
-
const sessionArgs = [...signedIn.appleId ? ["--apple-id", signedIn.appleId] : [], ...chosen === void 0 ? [] : ["--public-provider-id", chosen]];
|
|
396
|
-
let created;
|
|
397
|
-
try {
|
|
398
|
-
created = await run("Creating the App Store Connect API key", [
|
|
399
|
-
"web",
|
|
400
|
-
"api-keys",
|
|
401
|
-
"create",
|
|
402
|
-
"--name",
|
|
403
|
-
name,
|
|
404
|
-
"--role",
|
|
405
|
-
role,
|
|
406
|
-
"--output-dir",
|
|
407
|
-
node_path.default.join(work.root, "keys"),
|
|
408
|
-
...sessionArgs,
|
|
409
|
-
"--output",
|
|
410
|
-
"json"
|
|
411
|
-
]);
|
|
412
|
-
} catch (error) {
|
|
413
|
-
const stderr = error instanceof AscCommandError && isRecord(error.cause) ? String(error.cause.stderr ?? "") : "";
|
|
414
|
-
const hint = createFailureHint(stderr);
|
|
415
|
-
if (hint && error instanceof AscCommandError) throw new AscCommandError(`${error.message}\n${hint}`, error.exitCode, { cause: error });
|
|
416
|
-
throw error;
|
|
417
|
-
}
|
|
418
|
-
const keyId = text(created.keyId);
|
|
419
|
-
if (!keyId) throw new AscCommandError("asc web api-keys create printed no key ID.", 0);
|
|
420
|
-
const revoke = () => run("Revoking the key", [
|
|
421
|
-
"web",
|
|
422
|
-
"api-keys",
|
|
423
|
-
"revoke",
|
|
424
|
-
"--key-id",
|
|
425
|
-
keyId,
|
|
426
|
-
"--type",
|
|
427
|
-
"team",
|
|
428
|
-
"--confirm",
|
|
429
|
-
...sessionArgs,
|
|
430
|
-
"--output",
|
|
431
|
-
"json"
|
|
432
|
-
]).then((json) => json.active === false || json.status === "revoked", () => false);
|
|
433
728
|
let p8;
|
|
434
729
|
let issuerId;
|
|
435
730
|
try {
|
|
436
|
-
|
|
437
|
-
const p8Path = text(created.p8Path);
|
|
438
|
-
const keys = node_path.default.join(work.root, "keys");
|
|
439
|
-
if (!issuer) throw new Error("asc printed no issuer ID for the key.");
|
|
440
|
-
if (!p8Path || await node_fs_promises.default.realpath(node_path.default.dirname(node_path.default.resolve(p8Path))) !== await node_fs_promises.default.realpath(keys)) throw new Error("asc saved the key's .p8 outside its output directory.");
|
|
441
|
-
p8 = (await node_fs_promises.default.readFile(p8Path, "utf-8")).trim();
|
|
731
|
+
p8 = await downloadP8(session, keyId, wait);
|
|
442
732
|
(0, _hot_updater_cli_tools.registerSecretValue)(p8);
|
|
443
733
|
(0, _hot_updater_cli_tools.registerSecretValue)(Buffer.from(p8).toString("base64"));
|
|
444
734
|
assertP8(p8);
|
|
735
|
+
const details = await irisRequest(session, "Reading the key's issuer ID", "GET", `/apiKeys/${encodeURIComponent(keyId)}?include=provider`);
|
|
736
|
+
const issuer = issuerOf(details) ?? issuerOf(data);
|
|
737
|
+
if (!issuer) throw new Error("App Store Connect named no issuer ID for the key.");
|
|
445
738
|
issuerId = issuer;
|
|
446
739
|
} catch (error) {
|
|
447
740
|
const revoked = await revoke();
|
|
@@ -451,13 +744,13 @@ const createAscCliKeyIssuer = ({ asc, env = process.env, prompts = cliPrompts }
|
|
|
451
744
|
p8,
|
|
452
745
|
keyId,
|
|
453
746
|
issuerId,
|
|
454
|
-
name: text(
|
|
455
|
-
team,
|
|
747
|
+
name: text(isRecord(data.attributes) ? data.attributes.nickname : "") ?? name,
|
|
748
|
+
team: toTeam(appleId, provider),
|
|
456
749
|
revoke,
|
|
457
|
-
dispose
|
|
750
|
+
dispose
|
|
458
751
|
};
|
|
459
752
|
} catch (error) {
|
|
460
|
-
await
|
|
753
|
+
await dispose();
|
|
461
754
|
throw error;
|
|
462
755
|
}
|
|
463
756
|
};
|
|
@@ -466,11 +759,11 @@ const createAscCliKeyIssuer = ({ asc, env = process.env, prompts = cliPrompts }
|
|
|
466
759
|
/** Where team API keys are listed and revoked in App Store Connect. */
|
|
467
760
|
const ASC_INTEGRATIONS_URL = "https://appstoreconnect.apple.com/access/integrations/api";
|
|
468
761
|
/**
|
|
469
|
-
* Hot Updater's sign-in, the one place that picks how it is done:
|
|
470
|
-
*
|
|
762
|
+
* Hot Updater's sign-in, the one place that picks how it is done: App Store
|
|
763
|
+
* Connect's own web sign-in, with no other tool. Another implementation of
|
|
471
764
|
* `IssueAscApiKey` replaces it here without touching its callers.
|
|
472
765
|
*/
|
|
473
|
-
const issueAscApiKey = (request) =>
|
|
766
|
+
const issueAscApiKey = (request) => createAscKeyIssuer()(request);
|
|
474
767
|
//#endregion
|
|
475
768
|
//#region src/utils/cocoapods.ts
|
|
476
769
|
const installPodsIfNeeded = async (sourceDir) => {
|