Files
SideImpactor/sms-2fa.patch
2026-04-28 02:14:19 +08:00

1178 lines
48 KiB
Diff

diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8f9a0e0..3ce9144 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { DirectUsbMuxClient } from 'webmuxd';
import type { AnisetteData } from './anisette-service';
-import type { AppleDeveloperContext } from './apple-signing';
+import type { AppleDeveloperContext, TwoFactorContext } from './apple-signing';
import { Header, type AppPage } from './components/Header';
import { LoginPage } from './components/LoginPage';
@@ -80,9 +80,13 @@ export function App() {
const [loginContext, setLoginContext] = useState<AppleDeveloperContext | null>(null);
const [savedAccounts, setSavedAccounts] = useState<StoredAccountSummary[]>(() => loadStoredAccountList());
const accountContextMapRef = useRef<Map<string, AppleDeveloperContext>>(new Map());
+ // Version counter so cachedAccountKeys useMemo re-runs when the map changes.
+ // useRef mutations don't trigger re-renders; this state variable does.
+ const [accountCacheVersion, setAccountCacheVersion] = useState(0);
// Login modal
const [loginModalOpen, setLoginModalOpen] = useState(false);
+ const [loginError, setLoginError] = useState<string | null>(null);
// Device
const [pairedDeviceInfo, setPairedDeviceInfo] = useState<PairedDeviceInfo | null>(null);
@@ -114,9 +118,12 @@ export function App() {
const [trustState, setTrustState] = useState<TrustModalState>(TRUST_MODAL_CLOSED);
const [twoFactor, setTwoFactor] = useState<{
open: boolean;
- submit: ((code: string) => void) | null;
+ ctx: TwoFactorContext | null;
error: string | null;
- }>({ open: false, submit: null, error: null });
+ }>({ open: false, ctx: null, error: null });
+ // Set to true by handleTwoFactorCancel so handleLogin's catch can distinguish
+ // a user-cancelled 2FA from a genuine verification failure.
+ const twoFactorCancelledRef = useRef(false);
// Derived
const isWebUsbSupported = useMemo(() => {
@@ -136,7 +143,7 @@ export function App() {
const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null;
- const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]);
+ const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [accountCacheVersion]);
const trustOpen = trustState !== TRUST_MODAL_CLOSED;
// ---- log + progress plumbing ----
@@ -183,6 +190,7 @@ export function App() {
useEffect(() => {
const restored = restorePersistedAccountContexts();
accountContextMapRef.current = restored;
+ setAccountCacheVersion((v) => v + 1);
const summary = loadStoredAccountSummary();
if (summary) {
const active = restored.get(accountKey(summary.appleId, summary.teamId));
@@ -332,13 +340,17 @@ export function App() {
}
setBusy((prev) => ({ ...prev, loginSign: true }));
+ twoFactorCancelledRef.current = false;
+ setLoginError(null);
let twoFactorOpened = false;
let twoFactorErrorShown = false;
try {
saveText(APPLE_ID_STORAGE_KEY, trimmedAppleId);
addLog('login: initializing anisette...');
- const { anisetteData: nextAnisette } = await ensureAnisetteData(anisetteData, addLog);
+ // Always fetch a fresh anisette OTP for login — the OTP is one-time-use
+ // and the cached value is consumed after any previous attempt (even failed).
+ const { anisetteData: nextAnisette } = await ensureAnisetteData(null, addLog);
setAnisetteData(nextAnisette);
setAnisetteProvisioned(true);
addLog('login: anisette ready, authenticating...');
@@ -348,17 +360,19 @@ export function App() {
password,
anisetteData: nextAnisette,
log: addLog,
- onTwoFactorRequired: (submit) => {
+ onTwoFactorRequired: (ctx) => {
twoFactorOpened = true;
- setTwoFactor({ open: true, submit, error: null });
+ setTwoFactor({ open: true, ctx, error: null });
addLog('login: 2FA required, opening verification dialog');
},
});
- setTwoFactor({ open: false, submit: null, error: null });
+ setTwoFactor({ open: false, ctx: null, error: null });
+ setLoginError(null);
addLog(`login: authenticated as ${context.appleId} / ${context.team.identifier}`);
const key = accountKey(context.appleId, context.team.identifier);
accountContextMapRef.current.set(key, context);
+ setAccountCacheVersion((v) => v + 1);
persistAccountSummary(context);
persistAccountSession(context, nextAnisette);
addLog('login: session persisted');
@@ -371,36 +385,43 @@ export function App() {
addLog('login: done, navigating to sign page');
navigateToPage('sign');
} catch (error) {
+ console.error('[login] caught error:', error);
const msg = formatError(error);
addLog(`login failed: ${msg}`);
- if (twoFactorOpened) {
+ // Only show the error inside the 2FA modal if it's still open and the user
+ // did NOT cancel — a cancellation is intentional, not an error.
+ if (twoFactorOpened && !twoFactorCancelledRef.current) {
setTwoFactor((prev) => ({ ...prev, error: msg }));
twoFactorErrorShown = true;
+ } else if (!twoFactorOpened) {
+ // Pre-2FA failure (wrong password, rate limit, etc.) — show in login modal.
+ setLoginError(msg);
}
} finally {
if (!twoFactorErrorShown) {
- setTwoFactor({ open: false, submit: null, error: null });
+ setTwoFactor({ open: false, ctx: null, error: null });
}
setBusy((prev) => ({ ...prev, loginSign: false }));
}
}, [addLog, anisetteData, appleId, clearPrepared, navigateToPage, password]);
- const handleTwoFactorSubmit = useCallback(
- (code: string) => {
- const submit = twoFactor.submit;
- if (submit) submit(code);
- },
- [twoFactor.submit],
- );
-
const handleTwoFactorCancel = useCallback(() => {
- const submit = twoFactor.submit;
- setTwoFactor({ open: false, submit: null, error: null });
- if (submit) {
+ const ctx = twoFactor.ctx;
+ twoFactorCancelledRef.current = true;
+ setTwoFactor({ open: false, ctx: null, error: null });
+ if (ctx) {
addLog('login: 2FA canceled');
- submit('__CANCELLED__');
+ // Signal cancellation via the device-code path with a sentinel value.
+ ctx.submitDeviceCode('__CANCELLED__');
}
- }, [addLog, twoFactor.submit]);
+ }, [addLog, twoFactor.ctx]);
+
+ const handleTwoFactorRetry = useCallback(() => {
+ // Close the modal (clearing the stale ctx + error) then re-run the full
+ // login flow so the user doesn't have to re-type their password.
+ setTwoFactor({ open: false, ctx: null, error: null });
+ void handleLogin();
+ }, [handleLogin]);
// ---- sign flow ----
const handleSign = useCallback(async () => {
@@ -534,6 +555,7 @@ export function App() {
// Remove from session map
removeStoredAccountSession(summary.appleId, summary.teamId);
accountContextMapRef.current.delete(key);
+ setAccountCacheVersion((v) => v + 1);
// Remove from account list
const list = loadStoredAccountList().filter(
@@ -598,7 +620,7 @@ export function App() {
<main className="min-h-screen bg-bg">
<Header currentPage={currentPage} onNavigate={navigateToPage} />
- <section className="mx-auto max-w-[760px] px-5 py-10 sm:px-7">
+ <section className="mx-auto max-w-190 px-5 py-10 sm:px-7">
{currentPage === 'login' ? (
<LoginPage
loggedIn={!!loginContext}
@@ -642,14 +664,15 @@ export function App() {
<LoginModal
open={loginModalOpen}
- onClose={() => setLoginModalOpen(false)}
+ onClose={() => { setLoginModalOpen(false); setLoginError(null); }}
appleId={appleId}
password={password}
busyLoginSign={busy.loginSign}
canSubmit={loginCanSubmit}
- onAppleIdChange={setAppleId}
+ error={loginError}
+ onAppleIdChange={(v) => { setAppleId(v); setLoginError(null); }}
onAppleIdBlur={handleAppleIdBlur}
- onPasswordChange={setPassword}
+ onPasswordChange={(v) => { setPassword(v); setLoginError(null); }}
onSubmit={handleLogin}
/>
@@ -668,9 +691,10 @@ export function App() {
/>
<TwoFactorModal
open={twoFactor.open}
- onSubmit={handleTwoFactorSubmit}
+ ctx={twoFactor.ctx}
onCancel={handleTwoFactorCancel}
serverError={twoFactor.error}
+ onRetry={handleTwoFactorRetry}
/>
</main>
);
diff --git a/frontend/src/apple-signing.ts b/frontend/src/apple-signing.ts
index b9c98a4..3c9bc24 100644
--- a/frontend/src/apple-signing.ts
+++ b/frontend/src/apple-signing.ts
@@ -57,11 +57,29 @@ export interface AppleDeveloperContext {
devices: Device[];
}
+export interface TrustedPhoneNumber {
+ id: number;
+ numberWithDialCode: string;
+ obfuscatedNumber: string;
+ pushMode: string;
+}
+
+export interface TwoFactorContext {
+ /** Submit a 6-digit code that was pushed to a trusted device. */
+ submitDeviceCode: (code: string) => void;
+ /** Available trusted phone numbers for SMS fallback. Empty when none. */
+ trustedPhoneNumbers: TrustedPhoneNumber[];
+ /** Request an SMS be sent to the given phone id, then call submitSmsCode. */
+ requestSms: (phoneId: number) => Promise<void>;
+ /** Submit the code received via SMS for the given phone id. */
+ submitSmsCode: (phoneId: number, code: string) => Promise<void>;
+}
+
export interface AppleDeveloperLoginRequest {
anisetteData: AnisetteData;
credentials: AppleSigningCredentials;
onLog?: (message: string) => void;
- onTwoFactorRequired?: (submitCode: (code: string) => void) => void;
+ onTwoFactorRequired?: (ctx: TwoFactorContext) => void;
}
export interface AppleSigningWithContextRequest {
@@ -101,8 +119,346 @@ interface AltsignModule {
}): Promise<{ data: Uint8Array }>;
}
-let appleApiInstance: AppleAPI | null = null;
+/** Minimal type alias for the Fetch instance used in the SMS patch. */
+type AltsignFetch = {
+ get(url: string, headers?: Record<string, string>): Promise<{ text(): Promise<string>; ok: boolean }>;
+ request(
+ method: string,
+ url: string,
+ headers: Record<string, string>,
+ body?: string,
+ ): Promise<{ text(): Promise<string>; ok: boolean }>;
+};
+
let altsignModulePromise: Promise<AltsignModule> | null = null;
+/** Cached fetch wrapper — the libcurl WASM underneath is expensive to re-init. */
+let appleFetchInstance: AltsignFetch | null = null;
+
+// ---------- Apple GSA SMS 2FA endpoints ----------
+
+const GSA_TRUSTED_DEVICE_URL = 'https://gsa.apple.com/auth/verify/trusteddevice';
+const GSA_VALIDATE_URL = 'https://gsa.apple.com/grandslam/GsService2/validate';
+/** Request an SMS be sent to a phone number (PUT, no `/put` suffix). */
+const GSA_PHONE_URL = 'https://gsa.apple.com/auth/verify/phone';
+const GSA_PHONE_CODE_URL = 'https://gsa.apple.com/auth/verify/phone/securitycode';
+
+/** Replace the private handleTwoFactor method on the auth sub-object so we get
+ * the trusted-phone list and can drive SMS verification from the UI. */
+function patchHandleTwoFactor(api: AppleAPI, fetch: AltsignFetch): void {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const auth: any = (api as any).auth;
+ if (!auth || typeof auth.handleTwoFactor !== 'function') return;
+
+ auth.handleTwoFactor = async function (
+ dsid: string,
+ idmsToken: string,
+ anisetteData: AnisetteData,
+ verificationHandler: ((ctx: TwoFactorContext) => void) | undefined,
+ ): Promise<boolean> {
+ if (!verificationHandler) return false;
+ try {
+ const identityToken = btoa(`${dsid}:${idmsToken}`);
+ const headers: Record<string, string> = {
+ 'Content-Type': 'text/x-xml-plist',
+ 'User-Agent': 'Xcode',
+ Accept: 'text/x-xml-plist',
+ 'Accept-Language': 'en-us',
+ 'X-Apple-App-Info': 'com.apple.gs.xcode.auth',
+ 'X-Xcode-Version': '11.2 (11B41)',
+ 'X-Apple-Identity-Token': identityToken,
+ 'X-Apple-I-MD-M': anisetteData.machineID,
+ 'X-Apple-I-MD': anisetteData.oneTimePassword,
+ 'X-Apple-I-MD-LU': anisetteData.localUserID,
+ 'X-Apple-I-MD-RINFO': String(anisetteData.routingInfo),
+ 'X-Mme-Device-Id': anisetteData.deviceUniqueIdentifier,
+ 'X-MMe-Client-Info': anisetteData.deviceDescription,
+ 'X-Apple-I-Client-Time': auth.formatDate(anisetteData.date),
+ 'X-Apple-Locale': anisetteData.locale,
+ 'X-Apple-I-TimeZone': anisetteData.timeZone,
+ };
+
+ // Trigger push to trusted devices AND get trusted phone numbers.
+ let trustedPhoneNumbers: TrustedPhoneNumber[] = [];
+ try {
+ const deviceResp = await fetch.get(GSA_TRUSTED_DEVICE_URL, headers);
+ const deviceText = await deviceResp.text();
+ trustedPhoneNumbers = parseTrustedPhones(deviceText);
+ console.log('[2FA] parsed trustedPhoneNumbers:', trustedPhoneNumbers);
+ if (trustedPhoneNumbers.length === 0) {
+ // Log key fragments to diagnose the response format
+ const snippets = [
+ deviceText.match(/"trustedPhoneNumbers"\s*:\s*(\[[\s\S]{0,300}?\])/)?.[1],
+ deviceText.match(/phoneNumbers[\s\S]{0,200}/)?.[0],
+ // Apple auth HTML embeds data in various script tags
+ deviceText.match(/window\.AUTH_INIT_DATA\s*=\s*({[\s\S]{0,400}?});/)?.[1],
+ deviceText.match(/var\s+bootstrap\s*=\s*({[\s\S]{0,400}?});/)?.[1],
+ deviceText.match(/<script[^>]*id="boot_args"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
+ deviceText.match(/<script[^>]*type="text\/x-apple-plist"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
+ deviceText.match(/<script[^>]*type="application\/json"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
+ ].filter(Boolean);
+ console.log('[2FA] phone fragments:', snippets);
+ }
+ } catch (e) {
+ console.warn('[2FA] trusteddevice request failed (non-fatal):', e);
+ }
+
+ return await new Promise<boolean>((resolve) => {
+ const jsonHeaders = {
+ ...headers,
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ };
+
+ const submitDeviceCode = async (code: string) => {
+ const trimmed = code.trim();
+ // '__CANCELLED__' sentinel: user dismissed the modal — skip network request.
+ if (!trimmed || trimmed === '__CANCELLED__') { resolve(false); return; }
+ try {
+ const verifyHeaders = { ...headers, 'security-code': trimmed };
+ const resp = await fetch.get(GSA_VALIDATE_URL, verifyHeaders);
+ const text = await resp.text();
+ const plist = parsePlistSimple(text);
+ resolve((plist as Record<string, unknown>)['ec'] === 0);
+ } catch { resolve(false); }
+ };
+
+ const requestSms = async (phoneId: number): Promise<void> => {
+ const body = JSON.stringify({ phoneNumber: { id: phoneId }, mode: 'sms' });
+ console.log('[2FA] requestSms → PUT', GSA_PHONE_URL, 'phoneId:', phoneId);
+ const resp = await fetch.request('PUT', GSA_PHONE_URL, jsonHeaders, body);
+ const respText = await resp.text();
+ console.log('[2FA] requestSms response ok:', resp.ok, 'body:', respText.slice(0, 200));
+ if (!resp.ok) throw new Error(`Failed to send SMS (${respText.slice(0, 100)})`);
+ };
+
+ const submitSmsCode = async (phoneId: number, code: string): Promise<void> => {
+ const body = JSON.stringify({
+ phoneNumber: { id: phoneId },
+ securityCode: { code: code.trim() },
+ mode: 'sms',
+ });
+ const resp = await fetch.request('POST', GSA_PHONE_CODE_URL, jsonHeaders, body);
+ const text = await resp.text();
+ console.log('[2FA] submitSmsCode response ok:', resp.ok, 'body:', text.slice(0, 200));
+ let ok = resp.ok;
+ try {
+ const parsed = JSON.parse(text) as Record<string, unknown>;
+ if (parsed['ec'] !== undefined) ok = parsed['ec'] === 0;
+ } catch { /* plist fallback */ }
+ if (!ok) throw new Error('SMS verification failed');
+ // SMS 2FA is complete — resolve(true) so altsign.js retries authenticate()
+ // with fresh anisette (handled by patchAuthenticateRetry).
+ resolve(true);
+ };
+
+ try {
+ verificationHandler({ submitDeviceCode, trustedPhoneNumbers, requestSms, submitSmsCode });
+ } catch { resolve(false); }
+ });
+ } catch {
+ return false;
+ }
+ };
+}
+
+/** patchAuthenticateRetry: on the post-2FA re-call, swap in a fresh anisette OTP
+ * so the SRP exchange doesn't crash on a stale one-time password.
+ *
+ * patchSendAuthRequest: Apple may return au:"secondaryAuth" instead of
+ * au:"trustedDeviceSecondaryAuth" for accounts without a trusted device (SMS-only
+ * 2FA). altsign.js only checks for the latter, so it silently falls through to
+ * fetchAuthToken which then crashes with atob(undefined). We normalize the value
+ * so altsign.js's existing 2FA path fires correctly.
+ *
+ * For accounts where Apple signals 2FA ONLY at the apptokens stage (no au in the
+ * SRP complete response), we intercept the empty apptokens response, trigger 2FA,
+ * then throw a sentinel so the authenticate wrapper performs a full fresh SRP
+ * exchange (Apple invalidates the SRP session after 2FA at this stage). */
+const REAUTH_SENTINEL = '__SIDEIMPACTOR_NEEDS_FULL_REAUTH__';
+
+function patchAuthenticateRetry(api: AppleAPI, _apiFetch: AltsignFetch): void {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const auth: any = (api as any).auth;
+ if (!auth || typeof auth.authenticate !== 'function') return;
+
+ // Store the verificationHandler from the current authenticate() call so the
+ // sendAuthRequest patch can forward it to handleTwoFactor when needed.
+ let storedVerificationHandler: ((ctx: TwoFactorContext) => void) | undefined;
+ // After 2FA completes, mark it done so we don't re-trigger if the second
+ // apptokens attempt also returns empty (shouldn't happen, but guards loops).
+ let twoFactorDone = false;
+
+ // --- patch sendAuthRequest ---
+ if (typeof auth.sendAuthRequest === 'function') {
+ const origSend: (...a: unknown[]) => Promise<Record<string, unknown>> =
+ auth.sendAuthRequest.bind(auth);
+ auth.sendAuthRequest = async function (...args: unknown[]): Promise<Record<string, unknown>> {
+ const params = args[0] as Record<string, unknown>;
+ const anisetteArg = args[1] as AnisetteData;
+
+ // Wrap origSend so we can intercept apptokens 2FA signals.
+ // Apple uses two different conventions to signal "2FA required at apptokens":
+ // (a) Older: returns a non-plist / empty body → parsePlist yields {} (no Status)
+ // (b) Newer: returns ec=-22421 "This action could not be completed. Try again."
+ // which altsign.js converts into a thrown Error before returning to us.
+ // We catch case (b) here and fall through to the shared 2FA-gate logic below.
+ let response: Record<string, unknown>;
+ try {
+ response = await origSend(...args);
+ } catch (e) {
+ const isApptokens = params?.['o'] === 'apptokens';
+ const is22421 = e instanceof Error && e.message.includes('(-22421)');
+ if (isApptokens && is22421 && !twoFactorDone) {
+ console.log('[auth] apptokens threw -22421 — treating as 2FA gate (Apple new-style signal)');
+ response = {}; // fall through to 2FA handling below
+ } else {
+ throw e;
+ }
+ }
+
+ const status = response['Status'] as Record<string, unknown> | undefined;
+ const au = status?.['au'];
+ console.log('[auth] sendAuthRequest o=%s au=%s keys=%o', params?.['o'], au, Object.keys(response));
+
+ // Normalize legacy au value
+ if (au === 'secondaryAuth') {
+ console.log('[auth] normalizing au: secondaryAuth → trustedDeviceSecondaryAuth');
+ (status as Record<string, unknown>)['au'] = 'trustedDeviceSecondaryAuth';
+ return response;
+ }
+
+ // Detect 2FA required at apptokens stage.
+ // Covers both empty response (case a) and the -22421 catch above (case b).
+ if (params?.['o'] === 'apptokens' && Object.keys(response).length === 0) {
+ if (twoFactorDone) {
+ // 2FA was already completed — this is a genuine failure, not a gate.
+ console.error('[auth] apptokens still empty/erroring after 2FA — authentication failed');
+ return response;
+ }
+ console.log('[auth] apptokens 2FA gate detected — triggering verification');
+ const adsid = params['u'] as string;
+ const idmsToken = params['t'] as string;
+ if (adsid && idmsToken && storedVerificationHandler) {
+ const success = await auth.handleTwoFactor(
+ adsid, idmsToken, anisetteArg, storedVerificationHandler,
+ );
+ if (success) {
+ // Apple invalidates the SRP session after 2FA at this stage.
+ // Throw a sentinel so loginAppleDeveloperAccount starts a fresh SRP exchange.
+ console.log('[auth] 2FA done — signalling full re-authentication');
+ twoFactorDone = true;
+ throw new Error(REAUTH_SENTINEL);
+ }
+ }
+ console.error('[auth] apptokens 2FA failed or no handler available');
+ }
+
+ return response;
+ };
+ } else {
+ console.warn('[auth] sendAuthRequest not found on auth — patch skipped');
+ }
+
+ // --- patch authenticate ---
+ // Responsibilities:
+ // 1. Capture verificationHandler so sendAuthRequest can pass it to handleTwoFactor.
+ // 2. On post-2FA retry (callCount > 1), swap in fresh anisette.
+ // 3. If REAUTH_SENTINEL is thrown from the apptokens path, re-throw it unchanged
+ // so loginAppleDeveloperAccount can catch it and do a clean fresh login with a
+ // new AppleAPI instance (avoids state-pollution in the current auth object).
+ const original: (...args: unknown[]) => Promise<unknown> = auth.authenticate.bind(auth);
+ let callCount = 0;
+
+ auth.authenticate = async function (...args: unknown[]): Promise<unknown> {
+ callCount++;
+ storedVerificationHandler = args[3] as ((ctx: TwoFactorContext) => void) | undefined;
+ console.log(`[auth] authenticate call #${callCount}`);
+
+ // Always use fresh anisette on any retry call (callCount > 1).
+ if (callCount > 1) {
+ try {
+ const { getAnisetteData } = await import('./anisette-service');
+ const freshAnisette = await getAnisetteData();
+ args = [...args];
+ args[2] = freshAnisette;
+ console.log('[auth] post-2FA retry: swapped in fresh anisette OTP');
+ } catch (e) {
+ console.warn('[auth] post-2FA retry: could not fetch fresh anisette, proceeding with original', e);
+ }
+ }
+
+ try {
+ return await original(...args);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ // Bubble the sentinel unchanged — loginAppleDeveloperAccount handles it
+ // by creating a fresh AppleAPI instance for a clean re-auth.
+ if (msg === REAUTH_SENTINEL) {
+ throw e;
+ }
+ console.error(`[auth] authenticate call #${callCount} threw:`, e);
+ throw e;
+ }
+ };
+}
+
+/** Extract `ec` from an Apple GSA response (JSON or XML plist). */
+function parsePlistSimple(text: string): unknown {
+ const trimmed = text.trim();
+ if (trimmed.startsWith('{')) {
+ try { return JSON.parse(trimmed); } catch { /* fall through */ }
+ }
+ // XML plist — reuse the file's own parser
+ return parseXmlPlist(trimmed);
+}
+
+function parseTrustedPhones(text: string): TrustedPhoneNumber[] {
+ const trimmed = text.trim();
+
+ // Path 1: plain JSON response { "trustedPhoneNumbers": [...] }
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
+ try {
+ const data = JSON.parse(trimmed) as Record<string, unknown>;
+ const phones = data['trustedPhoneNumbers'];
+ if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
+ } catch { /* fall through */ }
+ }
+
+ // Path 2: Apple Auth HTML — phone numbers are in
+ // <script type="application/json" class="boot_args">{ "direct": { "trustedDeviceVerification": { "phoneNumberVerification": { "trustedPhoneNumbers": [...] } } } }</script>
+ const bootArgsMatch = trimmed.match(
+ /<script[^>]+class="boot_args"[^>]*>([\s\S]*?)<\/script>/i,
+ );
+ if (bootArgsMatch) {
+ try {
+ const boot = JSON.parse(bootArgsMatch[1]) as Record<string, unknown>;
+ const tdv = (boot['direct'] as Record<string, unknown> | undefined)?.['trustedDeviceVerification'] as Record<string, unknown> | undefined;
+ const pnv = tdv?.['phoneNumberVerification'] as Record<string, unknown> | undefined;
+ const phones = pnv?.['trustedPhoneNumbers'];
+ if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
+ } catch { /* fall through */ }
+ }
+
+ // Path 3: XML plist (older Apple endpoints)
+ if (trimmed.startsWith('<?xml') || trimmed.startsWith('<plist')) {
+ const data = parseXmlPlist(trimmed);
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
+ const phones = (data as Record<string, unknown>)['trustedPhoneNumbers'];
+ if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
+ }
+ }
+
+ return [];
+}
+
+function mapPhones(raw: Record<string, unknown>[]): TrustedPhoneNumber[] {
+ return raw.map((p) => ({
+ id: typeof p['id'] === 'number' ? p['id'] : Number(p['id']),
+ numberWithDialCode: typeof p['numberWithDialCode'] === 'string' ? p['numberWithDialCode'] : '',
+ obfuscatedNumber: typeof p['obfuscatedNumber'] === 'string' ? p['obfuscatedNumber'] : '',
+ pushMode: typeof p['pushMode'] === 'string' ? p['pushMode'] : 'sms',
+ }));
+}
/**
* Keep `altsign.js` and its transitive `zsign-wasm` bundle out of the initial
@@ -117,26 +473,65 @@ async function loadAltsignModule(): Promise<AltsignModule> {
return await altsignModulePromise;
}
-async function getAppleApi(): Promise<AppleAPI> {
- if (appleApiInstance) {
- return appleApiInstance;
- }
+async function getAppleApi(log?: (msg: string) => void): Promise<{ api: AppleAPI; fetch: AltsignFetch }> {
+ const uiLog = log ?? (() => undefined);
const { AppleAPI, Fetch } = await loadAltsignModule();
- const appleFetch = new Fetch(initLibcurl, async (url, options) => {
- const libcurl = requireLibcurl();
- const response = await libcurl.fetch(url, {
- method: options.method,
- headers: options.headers,
- body: options.body,
- redirect: 'manual',
- insecure: true,
- verbose: 4,
- _libcurl_http_version: 1.1,
- } as never);
- return response;
- });
- appleApiInstance = new AppleAPI(appleFetch);
- return appleApiInstance;
+ if (!appleFetchInstance) {
+ const doFetch = async (url: string, options: {
+ method?: string;
+ headers?: HeadersInit;
+ body?: BodyInit | null;
+ }): Promise<Response> => {
+ const libcurl = requireLibcurl();
+ const libcurlOpts = {
+ method: options.method,
+ headers: options.headers,
+ body: options.body,
+ redirect: 'manual',
+ insecure: true,
+ verbose: 4,
+ _libcurl_http_version: 1.1,
+ } as never;
+ let response = await libcurl.fetch(url, libcurlOpts) as Response;
+ // Auto-retry on Apple GSA 503 (transient rate-limit / overload).
+ // Use exponential backoff: 5s, 10s, 20s, 40s — up to ~75s total.
+ if (url.includes('gsa.apple.com') && response.status === 503) {
+ const delays = [5000, 10000, 20000, 40000];
+ for (let attempt = 0; attempt < delays.length; attempt++) {
+ const waitSec = delays[attempt] / 1000;
+ const msg = `login: Apple server busy (503) — retrying in ${waitSec}s (${attempt + 1}/${delays.length})...`;
+ console.warn(`[fetch] ${msg}`);
+ uiLog(msg);
+ await new Promise((r) => setTimeout(r, delays[attempt]));
+ response = await libcurl.fetch(url, libcurlOpts) as Response;
+ if (response.status !== 503) break;
+ }
+ if (response.status === 503) {
+ throw new Error('Apple authentication server is temporarily unavailable (503). Please wait a few minutes and try again.');
+ }
+ }
+ // Log raw response body for Apple auth endpoint calls (diagnosis aid).
+ if (url.includes('gsa.apple.com')) {
+ const origText = response.text.bind(response);
+ response.text = async () => {
+ const text = await origText();
+ console.log('[fetch] raw text from %s status=%d (%d chars): %s',
+ url.split('?')[0], response.status, text.length, text.slice(0, 300));
+ return text;
+ };
+ }
+ return response;
+ };
+ const f = new Fetch(initLibcurl, doFetch);
+ appleFetchInstance = f as AltsignFetch;
+ }
+ // Always create a fresh AppleAPI per call — it holds no expensive state,
+ // but reusing it across separate authenticate() calls causes -22421 because
+ // the anisette OTP embedded in the previous call is already consumed.
+ const api = new AppleAPI(appleFetchInstance);
+ patchHandleTwoFactor(api, appleFetchInstance);
+ patchAuthenticateRetry(api, appleFetchInstance);
+ return { api, fetch: appleFetchInstance };
}
export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginRequest): Promise<AppleDeveloperContext> {
@@ -149,24 +544,46 @@ export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginReq
const log = request.onLog ?? (() => undefined);
log(`Login stage: authenticating Apple account ${maskEmail(appleId)}...`);
- const api = await getAppleApi();
- const { session } = await api.authenticate(
- appleId,
- password,
- request.anisetteData,
- (submitCode: (code: string) => void) => {
- if (!request.onTwoFactorRequired) {
- throw new Error('2FA required but no in-page handler provided');
- }
- request.onTwoFactorRequired((code) => {
- const normalized = code.trim();
- if (normalized.length === 0) {
- throw new Error('2FA code is required');
- }
- submitCode(normalized);
- });
- },
- );
+ // Reset the shared fetch instance so each login attempt starts with a fresh
+ // libcurl TCP connection — prevents state bleed between accounts and between
+ // a cancelled-then-retried login on the same account.
+ appleFetchInstance = null;
+
+ const verificationCallback = (submitCode: (code: string) => void) => {
+ if (!request.onTwoFactorRequired) {
+ throw new Error('2FA required but no in-page handler provided');
+ }
+ // The patched handleTwoFactor passes a TwoFactorContext; the raw altsign.js
+ // VerificationHandler signature still receives `submitCode` as first arg,
+ // but our patch replaces that with the full context object.
+ const ctx = submitCode as unknown as TwoFactorContext;
+ request.onTwoFactorRequired(ctx);
+ };
+
+ let { api } = await getAppleApi(log);
+
+ let session: AppleDeveloperSession;
+ try {
+ ({ session } = await api.authenticate(appleId, password, request.anisetteData, verificationCallback));
+ } catch (e) {
+ // After 2FA at the apptokens stage, Apple invalidates the SRP session.
+ // The sendAuthRequest patch throws REAUTH_SENTINEL to signal this.
+ // We create a completely fresh AppleAPI (new auth state, new patches) and
+ // retry the full SRP exchange with fresh anisette.
+ if (e instanceof Error && e.message === REAUTH_SENTINEL) {
+ log('Login stage: 2FA verified — restarting SRP with fresh session...');
+ // Reset the shared fetch instance so the re-auth uses a fresh libcurl
+ // TCP connection to Apple — eliminates connection-state as a failure cause.
+ appleFetchInstance = null;
+ const fresh = await getAppleApi(log);
+ api = fresh.api;
+ const { getAnisetteData } = await import('./anisette-service');
+ const freshAnisette = await getAnisetteData();
+ ({ session } = await api.authenticate(appleId, password, freshAnisette, verificationCallback));
+ } else {
+ throw e;
+ }
+ }
log('Login stage: fetching team/certificates/devices...');
const team = await api.fetchTeam(session);
@@ -191,7 +608,7 @@ export async function refreshAppleDeveloperContext(
onLog?: (message: string) => void,
): Promise<AppleDeveloperContext> {
const log = onLog ?? (() => undefined);
- const api = await getAppleApi();
+ const { api } = await getAppleApi();
log('Signing stage: refreshing team/certificates/devices...');
const team = await api.fetchTeam(context.session);
const [certificates, devices] = await Promise.all([
@@ -234,7 +651,7 @@ export async function signIpaWithAppleContext(request: AppleSigningWithContextRe
throw new Error('Cannot sign IPA: bundle identifier is missing');
}
- const api = await getAppleApi();
+ const { api } = await getAppleApi();
const team = context.team;
onLog(`Signing stage: using team ${team.identifier} (${team.name}).`);
diff --git a/frontend/src/components/LoginModal.tsx b/frontend/src/components/LoginModal.tsx
index 7348eda..0b54926 100644
--- a/frontend/src/components/LoginModal.tsx
+++ b/frontend/src/components/LoginModal.tsx
@@ -9,6 +9,7 @@ interface LoginModalProps {
password: string;
busyLoginSign: boolean;
canSubmit: boolean;
+ error?: string | null;
onAppleIdChange: (value: string) => void;
onAppleIdBlur: () => void;
onPasswordChange: (value: string) => void;
@@ -22,6 +23,7 @@ export function LoginModal({
password,
busyLoginSign,
canSubmit,
+ error,
onAppleIdChange,
onAppleIdBlur,
onPasswordChange,
@@ -31,7 +33,7 @@ export function LoginModal({
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 backdrop-blur-sm p-4 pt-[8vh]">
- <div className="w-full max-w-[440px] rounded-2xl border border-border bg-bg p-6 shadow-2xl anim-in">
+ <div className="w-full max-w-110 rounded-2xl border border-border bg-bg p-6 shadow-2xl anim-in">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-[18px] font-semibold tracking-tight text-ink">Add Account</h2>
<button
@@ -83,22 +85,29 @@ export function LoginModal({
<p className="text-[11.5px] text-muted">
Your credentials are stored locally in this browser and are sent directly to Apple.
</p>
- <p className="text-[11.5px] text-[var(--color-danger)] underline underline-offset-2 decoration-[var(--color-danger)]/40">
+ <p className="text-[11.5px] text-danger underline underline-offset-2 decoration-danger/40">
Verify that you trust the server hosting this page. A compromised server can intercept your credentials.
</p>
</div>
- <div className="mt-5 flex justify-end">
- <Button
- variant="primary"
- busy={busyLoginSign}
- busyLabel="Signing In…"
- disabled={!canSubmit}
- onClick={onSubmit}
- className="min-w-[140px]"
- >
- Sign In
- </Button>
+ <div className="mt-5 flex flex-col gap-3">
+ {error && (
+ <p className="rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[12.5px] text-danger">
+ {error}
+ </p>
+ )}
+ <div className="flex justify-end">
+ <Button
+ variant="primary"
+ busy={busyLoginSign}
+ busyLabel="Signing In…"
+ disabled={!canSubmit}
+ onClick={onSubmit}
+ className="min-w-35"
+ >
+ Sign In
+ </Button>
+ </div>
</div>
</div>
</div>
diff --git a/frontend/src/components/TwoFactorModal.tsx b/frontend/src/components/TwoFactorModal.tsx
index da36943..eaa1a28 100644
--- a/frontend/src/components/TwoFactorModal.tsx
+++ b/frontend/src/components/TwoFactorModal.tsx
@@ -1,40 +1,122 @@
import { useEffect, useRef, useState } from 'react';
import { Modal } from './ui/Modal';
import { Button } from './ui/Button';
+import type { TrustedPhoneNumber, TwoFactorContext } from '../apple-signing';
+
+type Mode = 'device' | 'sms';
interface TwoFactorModalProps {
open: boolean;
- onSubmit: (code: string) => void;
+ ctx: TwoFactorContext | null;
onCancel: () => void;
- /** Server-side error (e.g. wrong code). Shown when the login flow rejects after 2FA submit. */
+ /** Server-side error passed back from the login flow after a failed verify. */
serverError?: string | null;
+ /** Called when the user wants to retry the entire login after a server error. */
+ onRetry?: () => void;
}
-export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFactorModalProps) {
+export function TwoFactorModal({ open, ctx, onCancel, serverError, onRetry }: TwoFactorModalProps) {
+ const [mode, setMode] = useState<Mode>('device');
+ const [selectedPhone, setSelectedPhone] = useState<TrustedPhoneNumber | null>(null);
const [code, setCode] = useState('');
- const [error, setError] = useState<string | null>(null);
+ const [localError, setLocalError] = useState<string | null>(null);
+ const [smsSent, setSmsSent] = useState(false);
+ const [smsBusy, setSmsBusy] = useState(false);
+ const [verifyBusy, setVerifyBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
- const displayError = serverError || error;
+ const displayError = serverError || localError;
+ const phones = ctx?.trustedPhoneNumbers ?? [];
+
+ // Reset state on open/close
useEffect(() => {
if (open) {
+ setMode('device');
setCode('');
- setError(null);
+ setLocalError(null);
+ setSmsSent(false);
+ setSmsBusy(false);
+ setVerifyBusy(false);
+ setSelectedPhone(phones.length > 0 ? phones[0] : null);
const timer = window.setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => window.clearTimeout(timer);
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
- const handleSubmit = () => {
+ // Focus input when switching modes or after SMS sent
+ useEffect(() => {
+ if (open && (mode === 'device' || smsSent)) {
+ const timer = window.setTimeout(() => inputRef.current?.focus(), 50);
+ return () => window.clearTimeout(timer);
+ }
+ }, [mode, smsSent, open]);
+
+ const switchToSms = () => {
+ setMode('sms');
+ setCode('');
+ setLocalError(null);
+ setSmsSent(false);
+ };
+
+ const switchToDevice = () => {
+ setMode('device');
+ setCode('');
+ setLocalError(null);
+ };
+
+ const handleRequestSms = async () => {
+ if (!ctx || !selectedPhone) return;
+ setSmsBusy(true);
+ setLocalError(null);
+ try {
+ await ctx.requestSms(selectedPhone.id);
+ setSmsSent(true);
+ } catch (err) {
+ setLocalError(err instanceof Error ? err.message : 'Failed to send SMS');
+ } finally {
+ setSmsBusy(false);
+ }
+ };
+
+ const handleSubmit = async () => {
const trimmed = code.trim();
if (trimmed.length === 0) {
- setError('Please enter verification code.');
+ setLocalError('Please enter the verification code.');
return;
}
- onSubmit(trimmed);
+ if (!ctx) return;
+
+ setVerifyBusy(true);
+ setLocalError(null);
+ try {
+ if (mode === 'device') {
+ ctx.submitDeviceCode(trimmed);
+ // submitDeviceCode resolves the outer Promise — no async result here
+ } else {
+ if (!selectedPhone) return;
+ await ctx.submitSmsCode(selectedPhone.id, trimmed);
+ }
+ } catch (err) {
+ setLocalError(err instanceof Error ? err.message : 'Verification failed');
+ setVerifyBusy(false);
+ }
+ // On success the parent modal will close; don't clear busy so button stays
+ // disabled until the modal is removed.
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (mode === 'sms' && !smsSent) {
+ void handleRequestSms();
+ } else {
+ void handleSubmit();
+ }
+ }
};
return (
@@ -42,44 +124,152 @@ export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFac
<h2 id="two-factor-title" className="text-[16px] font-semibold tracking-tight text-ink">
Two-Factor Authentication
</h2>
- <p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
- Enter the verification code from your trusted Apple device.
- </p>
-
- <label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
- Verification Code
- </label>
- <input
- ref={inputRef}
- id="two-factor-code"
- type="text"
- inputMode="numeric"
- autoComplete="one-time-code"
- maxLength={8}
- placeholder="123456"
- className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
- value={code}
- onChange={(e) => {
- setCode(e.target.value);
- if (error) setError(null);
- }}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleSubmit();
- }
- }}
- />
- <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
-
- <div className="mt-4 grid grid-cols-2 gap-2">
- <Button variant="ghost" onClick={onCancel}>
- Cancel
- </Button>
- <Button variant="primary" onClick={handleSubmit} disabled={!!serverError}>
- Verify
- </Button>
- </div>
+
+ {mode === 'device' ? (
+ <>
+ <p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
+ Enter the verification code from your trusted Apple device or Mac.
+ </p>
+
+ <label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
+ Verification Code
+ </label>
+ <input
+ ref={inputRef}
+ id="two-factor-code"
+ type="text"
+ inputMode="numeric"
+ autoComplete="one-time-code"
+ maxLength={8}
+ placeholder="123456"
+ className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
+ value={code}
+ onChange={(e) => { setCode(e.target.value); setLocalError(null); }}
+ onKeyDown={handleKeyDown}
+ />
+ <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
+
+ <div className="mt-4 grid grid-cols-2 gap-2">
+ <Button variant="ghost" onClick={onCancel}>Cancel</Button>
+ {serverError ? (
+ <Button variant="primary" onClick={onRetry}>
+ Retry Login
+ </Button>
+ ) : (
+ <Button
+ variant="primary"
+ busy={verifyBusy}
+ busyLabel="Verifying…"
+ onClick={() => void handleSubmit()}
+ disabled={verifyBusy}
+ >
+ Verify
+ </Button>
+ )}
+ </div>
+
+ {phones.length > 0 && (
+ <button
+ type="button"
+ onClick={switchToSms}
+ className="mt-3 w-full text-center text-[12px] text-muted underline underline-offset-2 hover:text-ink transition-colors"
+ >
+ Get SMS instead →
+ </button>
+ )}
+ </>
+ ) : (
+ <>
+ <p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
+ {smsSent
+ ? 'Enter the code sent via SMS.'
+ : 'Choose a phone number to receive a verification code.'}
+ </p>
+
+ {/* Phone picker */}
+ {!smsSent && phones.length > 1 && (
+ <div className="mt-4 space-y-1">
+ {phones.map((phone) => (
+ <button
+ key={phone.id}
+ type="button"
+ onClick={() => setSelectedPhone(phone)}
+ className={`w-full rounded-lg border px-3 py-2 text-left text-[13px] transition-colors ${
+ selectedPhone?.id === phone.id
+ ? 'border-blue-500 bg-blue-500/10 text-ink'
+ : 'border-border text-muted hover:border-blue-400 hover:text-ink'
+ }`}
+ >
+ {phone.numberWithDialCode || phone.obfuscatedNumber}
+ </button>
+ ))}
+ </div>
+ )}
+
+ {!smsSent && phones.length === 1 && (
+ <p className="mt-3 rounded-lg border border-border px-3 py-2 text-[13px] text-muted">
+ {phones[0].numberWithDialCode || phones[0].obfuscatedNumber}
+ </p>
+ )}
+
+ <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{localError ?? ''}</p>
+
+ {!smsSent ? (
+ <div className="mt-2 grid grid-cols-2 gap-2">
+ <Button variant="ghost" onClick={switchToDevice}>Back</Button>
+ <Button
+ variant="primary"
+ busy={smsBusy}
+ busyLabel="Sending…"
+ disabled={!selectedPhone || smsBusy}
+ onClick={() => void handleRequestSms()}
+ >
+ Send SMS
+ </Button>
+ </div>
+ ) : (
+ <>
+ <label htmlFor="sms-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
+ SMS Code
+ </label>
+ <input
+ ref={inputRef}
+ id="sms-code"
+ type="text"
+ inputMode="numeric"
+ autoComplete="one-time-code"
+ maxLength={8}
+ placeholder="123456"
+ className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
+ value={code}
+ onChange={(e) => { setCode(e.target.value); setLocalError(null); }}
+ onKeyDown={handleKeyDown}
+ />
+ <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
+ <div className="mt-4 grid grid-cols-2 gap-2">
+ <Button variant="ghost" onClick={() => { setSmsSent(false); setCode(''); }}>
+ Resend
+ </Button>
+ {serverError ? (
+ <Button variant="primary" onClick={onRetry}>
+ Retry Login
+ </Button>
+ ) : (
+ <Button
+ variant="primary"
+ busy={verifyBusy}
+ busyLabel="Verifying…"
+ onClick={() => void handleSubmit()}
+ disabled={verifyBusy}
+ >
+ Verify
+ </Button>
+ )}
+ </div>
+ </>
+ )}
+ </>
+ )}
</Modal>
);
}
diff --git a/frontend/src/flows/login.ts b/frontend/src/flows/login.ts
index f70177e..306787b 100644
--- a/frontend/src/flows/login.ts
+++ b/frontend/src/flows/login.ts
@@ -1,5 +1,5 @@
import type { AnisetteData } from '../anisette-service';
-import type { AppleDeveloperContext } from '../apple-signing';
+import type { AppleDeveloperContext, TwoFactorContext } from '../apple-signing';
import { shortToken } from '../lib/ids';
type AnisetteService = typeof import('../anisette-service');
@@ -61,7 +61,7 @@ export interface LoginRequest {
password: string;
anisetteData: AnisetteData;
log: (msg: string) => void;
- onTwoFactorRequired: (submit: (code: string) => void) => void;
+ onTwoFactorRequired: (ctx: TwoFactorContext) => void;
}
export async function loginAccount(req: LoginRequest): Promise<AppleDeveloperContext> {