diff --git a/backend/package-lock.json b/backend/package-lock.json index 2b1948b4..efbfb829 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "bplist-parser": "^0.3.2", "express": "^4.21.2", "plist": "^3.1.0", + "unbzip2-stream": "^1.4.3", "uuid": "^11.0.5", "yauzl-promise": "^4.0.0" }, @@ -2040,6 +2041,30 @@ "node": ">= 5.10.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bufferutil": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", @@ -2936,6 +2961,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -3864,6 +3909,12 @@ "dev": true, "license": "MIT" }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4017,6 +4068,16 @@ "node": ">=14.17" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/undici": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", diff --git a/backend/package.json b/backend/package.json index 16861660..ce99bc30 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "bplist-parser": "^0.3.2", "express": "^4.21.2", "plist": "^3.1.0", + "unbzip2-stream": "^1.4.3", "uuid": "^11.0.5", "yauzl-promise": "^4.0.0" }, diff --git a/backend/src/index.ts b/backend/src/index.ts index 7151d943..a533d399 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -14,6 +14,7 @@ import packageRoutes from "./routes/packages.js"; import installRoutes from "./routes/install.js"; import settingsRoutes from "./routes/settings.js"; import bagRoutes from "./routes/bag.js"; +import sapRoutes from "./routes/sap.js"; const app = express(); @@ -30,6 +31,7 @@ app.use("/api", packageRoutes); app.use("/api", installRoutes); app.use("/api", settingsRoutes); app.use("/api", bagRoutes); +app.use("/api", sapRoutes); // Serve static frontend files const publicDir = path.resolve(import.meta.dirname, "../public"); diff --git a/backend/src/routes/sap.ts b/backend/src/routes/sap.ts new file mode 100644 index 00000000..53eaf8ec --- /dev/null +++ b/backend/src/routes/sap.ts @@ -0,0 +1,194 @@ +import express, { Router, Request, Response } from "express"; +import { createReadStream } from "fs"; +import path from "path"; +import { + REQUIRED_ASSETS, + assetDirectory, + fetchAssets, + verifyAsset, + verifyAssets, + type FetchProgress, +} from "../services/sapAssets.js"; + +const userAgent = + "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6"; + +// Serves the four Apple binaries the browser-side SAP signer runs under +// emulation. They are public components of a 2013 OS X release, carry no +// credentials, and are identical for every user, so they are plain static +// files rather than anything per-account. +// +// They are not in the image: they are large, they belong to Apple, and the +// signer only needs them when someone signs in. Populate DATA_DIR/sap with +// tools/fetch-sap-assets.mjs. + +const router = Router(); + +// One download at a time, with its state readable while it runs, so a second +// caller watches rather than starting a duplicate. +let fetching: Promise | null = null; +let progress: FetchProgress | null = null; +let lastError: string | null = null; + +router.post("/sap/assets/fetch", (_req: Request, res: Response) => { + if (!fetching) { + lastError = null; + progress = { stage: "locating", found: [] }; + + console.log("SAP assets: fetching from Apple"); + fetching = fetchAssets((update) => { + progress = update; + console.log( + `SAP assets: ${update.stage}` + + (update.found.length ? ` (${update.found.join(", ")})` : ""), + ); + }) + .catch((error) => { + lastError = error instanceof Error ? error.message : String(error); + console.error("SAP assets: fetch failed:", lastError); + throw error; + }) + .finally(() => { + fetching = null; + }); + + // Answered immediately; the caller polls GET for how it is going. + fetching.catch(() => {}); + } + + res.status(202).json({ started: true, progress }); +}); + +router.get("/sap/assets", async (_req: Request, res: Response) => { + // Digests, not sizes: a file of the right length but wrong contents loads + // and then fails deep inside the emulator with nothing pointing back here. + // Verdicts are cached until a file changes, so this is cheap to poll. + const { ok, missing, corrupt } = await verifyAssets(); + + res.json({ + available: ok, + missing: [...missing, ...corrupt], + corrupt, + ready: missing.length === 0 && corrupt.length === 0, + fetching: fetching !== null, + progress, + error: lastError, + }); +}); + +router.get("/sap/assets/:name", async (req: Request, res: Response) => { + // Express 5 types a route parameter as string | string[]; the lookup below + // is what makes it safe either way. + const name = String(req.params.name ?? ""); + const spec = REQUIRED_ASSETS.find((asset) => asset.name === name); + + // Only the four known names, so the parameter can never walk the filesystem. + if (!spec) { + res.status(404).json({ error: "Unknown SAP asset" }); + return; + } + + const state = await verifyAsset(spec); + + if (state === "missing") { + res.status(503).json({ + error: `SAP asset ${name} is not installed`, + hint: "POST /api/sap/assets/fetch to download it from Apple", + }); + return; + } + + if (state === "corrupt") { + res.status(500).json({ + error: `SAP asset ${name} does not match its digest`, + hint: "POST /api/sap/assets/fetch to replace it", + }); + return; + } + + const file = path.join(assetDirectory(), name); + console.log(`SAP assets: serving ${name} (${spec.size} bytes)`); + res.type("application/octet-stream"); + res.setHeader("Content-Length", String(spec.size)); + // Immutable: these are fixed files from a 2013 release. + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + createReadStream(file).pipe(res); +}); + +// The two SAP setup endpoints, proxied. +// +// Setting up a signer takes one fetch of Apple's certificate and one exchange +// of an opaque buffer. Neither carries credentials — the only identity in the +// handshake is the device's hardware id, which is the guid the client already +// sends in the clear — so this is the same kind of proxy as /api/bag and does +// not weaken the guarantee that the server never sees Apple credentials. +// +// Proxied rather than tunnelled because the signer runs in a Web Worker, +// where standing up a second Wisp client would buy nothing: there is no +// secret here to keep from the server. + +const SETUP_HOSTS: Record = { + certificate: "https://s.mzstatic.com/sap/setupCert.plist", + setup: "https://fpinit.itunes.apple.com/v1/signSapSetup/legacy", +}; + +const SAP_TIMEOUT_MS = 30_000; +const SAP_MAX_BYTES = 1 << 20; + +async function relay( + target: string, + init: RequestInit, + res: Response, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), SAP_TIMEOUT_MS); + + try { + const upstream = await fetch(target, { ...init, signal: controller.signal }); + const body = Buffer.from(await upstream.arrayBuffer()); + + if (body.length > SAP_MAX_BYTES) { + res.status(502).json({ error: "SAP response is too large" }); + return; + } + + if (!upstream.ok) { + res.status(502).json({ error: `Apple returned ${upstream.status}` }); + return; + } + + res.type("application/x-plist").send(body); + } catch (error) { + console.error("SAP proxy error:", error instanceof Error ? error.message : error); + res.status(502).json({ error: "SAP request failed" }); + } finally { + clearTimeout(timer); + } +} + +router.get("/sap/certificate", async (_req: Request, res: Response) => { + console.log("SAP setup: fetching Apple's certificate"); + await relay(SETUP_HOSTS.certificate, { + headers: { "User-Agent": userAgent, Accept: "application/x-plist" }, + }, res); +}); + +router.post( + "/sap/setup", + express.raw({ type: "*/*", limit: "1mb" }), + async (req: Request, res: Response) => { + if (!Buffer.isBuffer(req.body) || req.body.length === 0) { + res.status(400).json({ error: "Missing SAP setup buffer" }); + return; + } + + console.log(`SAP setup: exchanging ${req.body.length} bytes with Apple`); + await relay(SETUP_HOSTS.setup, { + method: "POST", + headers: { "User-Agent": userAgent, "Content-Type": "application/x-plist" }, + body: new Uint8Array(req.body), + }, res); + }, +); + +export default router; diff --git a/backend/src/services/sapAssets.ts b/backend/src/services/sapAssets.ts new file mode 100644 index 00000000..1a337f6a --- /dev/null +++ b/backend/src/services/sapAssets.ts @@ -0,0 +1,348 @@ +// Downloads the Apple binaries the browser-side SAP signer runs under +// emulation, straight from Apple's software update CDN. +// +// They live inside a 2013 OS X update package: a xar container holding a +// bzip2-compressed cpio archive. Only four files out of it are wanted and the +// package is far larger than they are, so this reads it with range requests +// and stops as soon as it has them, the way ipatool's internal/sap/assets +// does. +// +// The result is written to DATA_DIR/sap and reused forever after — the files +// come from a fixed release and never change. Each is checked against its +// SHA-256 before being kept. + +import { createHash } from "crypto"; +import { mkdir, readFile, rename, stat, writeFile } from "fs/promises"; +import path from "path"; +import { Readable } from "stream"; +import { inflateSync } from "zlib"; +import unbzip2 from "unbzip2-stream"; +import { config } from "../config.js"; + +const UPDATE_URL = + "https://swcdn.apple.com/content/downloads/27/34/041-98128-A_SYPWICN3KH/5dqkl4rqgbsr18yzy61yeie9g3cmjc5hiv/OSXUpd10.9.pkg"; + +// Where the bzip2 stream resumes inside the payload, and how far past the +// start of the decompressed data the cpio archive begins. Both are properties +// of this particular package. +const PAYLOAD_BZ_OFFSET = 0x352f40d5; +const PAYLOAD_CPIO_SKIP = 0x3a4; + +const XAR_MAGIC = 0x78617221; // "xar!" + +interface AssetSpec { + name: string; + path: string; + size: number; + sha256: string; +} + +export const REQUIRED_ASSETS: AssetSpec[] = [ + { + name: "CommerceKit", + path: "./System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/CommerceKit", + size: 3271840, + sha256: "b84ff12c21987856c0a17b78f1ad82b73195a6dec5f3b208a17d245555a2c8a2", + }, + { + name: "CommerceCore", + path: "./System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Frameworks/CommerceCore.framework/Versions/A/CommerceCore", + size: 207744, + sha256: "c5401e57402230f3c876409d295319ddf1e61287bc882683c5d61277be7bc1f2", + }, + { + name: "CoreFP", + path: "./System/Library/PrivateFrameworks/CoreFP.framework/Versions/A/CoreFP", + size: 29014912, + sha256: "f19141336be4198d0f8991bb00017c915efc7aeaece36c345f7faa1237ea6074", + }, + { + name: "CoreFP.icxs", + path: "./System/Library/PrivateFrameworks/CoreFP.framework/Versions/A/CoreFP.icxs", + size: 5288352, + sha256: "473e78af86979f5bd4f6269561caf770b3d16c098d918846eeac8cdd2fe6566a", + }, +]; + +export function assetDirectory(): string { + return path.join(config.dataDir, "sap"); +} + +export type AssetState = "ok" | "missing" | "corrupt"; + +// Digesting 38 MB on every request would be wasteful, and these files never +// change once written, so a verdict is kept until the file does. Size and +// modification time are enough to notice a replacement. +const verdicts = new Map(); + +/** + * Checks one asset against its recorded size and digest. + * + * The size alone is not enough: a file of the right length but wrong contents + * loads fine and then fails somewhere deep inside the emulator, with nothing + * to connect the fault back to a bad download. + */ +export async function verifyAsset(spec: AssetSpec): Promise { + const file = path.join(assetDirectory(), spec.name); + + let info: Awaited>; + try { + info = await stat(file); + } catch { + verdicts.delete(spec.name); + return "missing"; + } + + const cached = verdicts.get(spec.name); + if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) { + return cached.state; + } + + let state: AssetState = "corrupt"; + if (info.size === spec.size) { + const digest = createHash("sha256").update(await readFile(file)).digest("hex"); + state = digest === spec.sha256 ? "ok" : "corrupt"; + } + + if (state === "corrupt") { + console.error( + `SAP assets: ${spec.name} is ${info.size} bytes and does not match its digest`, + ); + } + + verdicts.set(spec.name, { size: info.size, mtimeMs: info.mtimeMs, state }); + return state; +} + +/** Verifies every asset, reporting which are usable and which are not. */ +export async function verifyAssets(): Promise<{ + ok: string[]; + missing: string[]; + corrupt: string[]; +}> { + const ok: string[] = []; + const missing: string[] = []; + const corrupt: string[] = []; + + for (const spec of REQUIRED_ASSETS) { + const state = await verifyAsset(spec); + if (state === "ok") ok.push(spec.name); + else if (state === "missing") missing.push(spec.name); + else corrupt.push(spec.name); + } + + return { ok, missing, corrupt }; +} + +async function range(start: number, end?: number): Promise { + const response = await fetch(UPDATE_URL, { + headers: { Range: `bytes=${start}-${end ?? ""}` }, + }); + + if (response.status !== 206 && response.status !== 200) { + throw new Error(`Apple returned ${response.status} for the update package`); + } + + return response; +} + +/** Locates the Payload member inside the xar container. */ +async function locatePayload(): Promise<{ offset: number; length: number }> { + const head = Buffer.from(await (await range(0, 27)).arrayBuffer()); + if (head.readUInt32BE(0) !== XAR_MAGIC) { + throw new Error("Apple update package is not a xar archive"); + } + + const headerSize = head.readUInt16BE(4); + const tocCompressed = Number(head.readBigUInt64BE(8)); + + const tocRaw = Buffer.from( + await (await range(headerSize, headerSize + tocCompressed - 1)).arrayBuffer(), + ); + const toc = inflateSync(tocRaw).toString("utf8"); + + // The table of contents is XML; the Payload's entry carries the offset and + // length of its bytes within the heap that follows the contents. + const entry = /]*>(?:(?!<\/file>)[\s\S])*?Payload<\/name>[\s\S]*?<\/file>/.exec(toc); + if (!entry) throw new Error("Apple update package has no Payload member"); + + const offset = /(\d+)<\/offset>/.exec(entry[0]); + const length = /(\d+)<\/length>/.exec(entry[0]); + if (!offset || !length) { + throw new Error("Apple update package Payload has no extent"); + } + + return { + offset: headerSize + tocCompressed + Number(offset[1]), + length: Number(length[1]), + }; +} + +/** + * Reads the old portable ASCII cpio format Apple's payload uses, handing each + * entry to `onEntry` and stopping as soon as it returns false. + */ +async function readCpio( + stream: AsyncIterable, + onEntry: (name: string, body: Buffer) => boolean, +): Promise { + const HEADER = 76; + let buffer = Buffer.alloc(0); + let done = false; + + for await (const chunk of stream) { + if (done) break; + buffer = Buffer.concat([buffer, chunk]); + + for (;;) { + if (buffer.length < HEADER) break; + if (buffer.subarray(0, 6).toString("ascii") !== "070707") { + throw new Error("Apple payload is not a portable ASCII cpio archive"); + } + + const nameSize = parseInt(buffer.subarray(59, 65).toString("ascii"), 8); + const fileSize = parseInt(buffer.subarray(65, 76).toString("ascii"), 8); + if (!Number.isFinite(nameSize) || !Number.isFinite(fileSize)) { + throw new Error("Apple payload has an unreadable cpio header"); + } + + const total = HEADER + nameSize + fileSize; + if (buffer.length < total) break; + + const name = buffer.subarray(HEADER, HEADER + nameSize - 1).toString("ascii"); + if (name === "TRAILER!!!") { + done = true; + break; + } + + const body = buffer.subarray(HEADER + nameSize, total); + if (!onEntry(name, body)) { + done = true; + break; + } + + buffer = buffer.subarray(total); + } + } +} + +export interface FetchProgress { + stage: "locating" | "downloading" | "verifying" | "done"; + found: string[]; +} + +/** + * Downloads and extracts the assets into DATA_DIR/sap. Files already present + * and the right size are left alone, so this is safe to call repeatedly. + */ +export async function fetchAssets( + onProgress?: (progress: FetchProgress) => void, +): Promise { + const target = assetDirectory(); + await mkdir(target, { recursive: true }); + + // Anything already present and verified is left alone; a corrupt file is + // replaced rather than skipped, which is the whole point of checking the + // digest rather than the size. + const existing = await verifyAssets(); + if (existing.ok.length === REQUIRED_ASSETS.length) { + onProgress?.({ stage: "done", found: existing.ok }); + return existing.ok; + } + + if (existing.corrupt.length) { + console.warn(`SAP assets: replacing corrupt ${existing.corrupt.join(", ")}`); + } + + onProgress?.({ stage: "locating", found: [] }); + const payload = await locatePayload(); + + onProgress?.({ stage: "downloading", found: [] }); + const response = await range( + payload.offset + PAYLOAD_BZ_OFFSET, + payload.offset + payload.length - 1, + ); + if (!response.body) throw new Error("Apple returned an empty payload stream"); + + // The range starts mid-stream at a block boundary, so the bzip2 header has + // to be put back in front of it. + const compressed = Readable.from( + (async function* () { + yield Buffer.from("BZh9", "ascii"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for await (const chunk of response.body as any) yield Buffer.from(chunk); + })(), + ); + + const wanted = new Map(REQUIRED_ASSETS.map((asset) => [asset.path, asset])); + const found: string[] = []; + const pending = new Map(); + + // unbzip2-stream is built on `through`, so it hands back an old-style + // stream that cannot be iterated; wrap turns it into one that can. + const decompressed = new Readable({ read() {} }).wrap( + compressed.pipe(unbzip2()), + ); + + let skipped = 0; + const archive = Readable.from( + (async function* () { + for await (const chunk of decompressed) { + let block = Buffer.from(chunk); + if (skipped < PAYLOAD_CPIO_SKIP) { + const drop = Math.min(PAYLOAD_CPIO_SKIP - skipped, block.length); + skipped += drop; + block = block.subarray(drop); + if (block.length === 0) continue; + } + yield block; + } + })(), + ); + + await readCpio(archive as AsyncIterable, (name, body) => { + const asset = wanted.get(name); + if (asset) { + pending.set(asset.name, Buffer.from(body)); + found.push(asset.name); + onProgress?.({ stage: "downloading", found: [...found] }); + } + return pending.size < wanted.size; + }); + + onProgress?.({ stage: "verifying", found: [...found] }); + + const written: string[] = []; + for (const asset of REQUIRED_ASSETS) { + const body = pending.get(asset.name); + if (!body) continue; + + if (body.length !== asset.size) { + throw new Error( + `${asset.name} is ${body.length} bytes, expected ${asset.size}`, + ); + } + + const digest = createHash("sha256").update(body).digest("hex"); + if (digest !== asset.sha256) { + throw new Error(`${asset.name} failed its digest check`); + } + + // Written aside then renamed, so a partial file is never served. + const destination = path.join(target, asset.name); + const temporary = `${destination}.partial`; + await writeFile(temporary, body); + await rename(temporary, destination); + written.push(asset.name); + } + + if (written.length !== REQUIRED_ASSETS.length) { + const missing = REQUIRED_ASSETS.filter( + (asset) => !written.includes(asset.name), + ).map((asset) => asset.name); + throw new Error(`Apple payload did not contain ${missing.join(", ")}`); + } + + onProgress?.({ stage: "done", found: written }); + return written; +} diff --git a/backend/src/types/unbzip2-stream.d.ts b/backend/src/types/unbzip2-stream.d.ts new file mode 100644 index 00000000..f1e9925d --- /dev/null +++ b/backend/src/types/unbzip2-stream.d.ts @@ -0,0 +1,5 @@ +// unbzip2-stream ships no types. It is a plain stream factory. +declare module "unbzip2-stream" { + import { Transform } from "stream"; + export default function unbzip2(): Transform; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 603cd874..37e4855a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "asspp-web", "version": "0.0.1", "dependencies": { + "@alexaltea/unicorn-js": "^2.1.4", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "idb": "^8.0.1", @@ -51,6 +52,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@alexaltea/unicorn-js": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@alexaltea/unicorn-js/-/unicorn-js-2.1.4.tgz", + "integrity": "sha512-UAml23tgHyXEuP2A7Y50qDCNYMIRHJobsjlhetPRrtH+PXg0+BNAyvPQEKoCd+VHAF1nDfPHE4yJCqSLVtOrXA==", + "license": "GPL-2.0" + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index cd2c676f..bd896016 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "test:watch": "vitest" }, "dependencies": { + "@alexaltea/unicorn-js": "^2.1.4", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "idb": "^8.0.1", diff --git a/frontend/src/apple/authenticate.ts b/frontend/src/apple/authenticate.ts index 61550f05..25901404 100644 --- a/frontend/src/apple/authenticate.ts +++ b/frontend/src/apple/authenticate.ts @@ -3,8 +3,16 @@ import { appleRequest } from "./request"; import { buildPlist, parsePlist } from "./plist"; import { extractAndMergeCookies } from "./cookies"; import { fetchBag, defaultAuthURL } from "./bag"; +import { prepareSigner, signAction, type SetupProgress } from "./sap/client"; import i18n from "../i18n"; +/** The signature travels in a header, so it goes out base64-encoded. */ +function base64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + export class AuthenticationError extends Error { constructor( message: string, @@ -21,11 +29,18 @@ export async function authenticate( code?: string, existingCookies?: Cookie[], deviceId: string = "", + onProgress?: (progress: SetupProgress) => void, ): Promise { let cookies: Cookie[] = existingCookies ? [...existingCookies] : []; let storeFront = ""; let lastError: Error | null = null; + // Apple gates this endpoint behind a SAP signature, and producing one means + // emulating Apple's own signing code — slow enough to want a progress + // report, and done off the main thread. Nothing in the setup sees the + // password: it only needs the device identifier. + await prepareSigner(deviceId, onProgress); + const defaultAuthEndpoint = new URL(defaultAuthURL); defaultAuthEndpoint.searchParams.set("guid", deviceId); let requestHost = defaultAuthEndpoint.hostname; @@ -55,8 +70,13 @@ export async function authenticate( const plistBody = buildPlist(body); + // Signing takes about twelve seconds, so say so rather than going quiet. + onProgress?.({ phase: "signing" }); + const signature = await signAction(new TextEncoder().encode(plistBody)); + const headers: Record = { "Content-Type": "application/x-apple-plist", + "X-Apple-ActionSignature": base64(signature), }; const response = await appleRequest({ diff --git a/frontend/src/apple/sap/README.md b/frontend/src/apple/sap/README.md new file mode 100644 index 00000000..0c854af6 --- /dev/null +++ b/frontend/src/apple/sap/README.md @@ -0,0 +1,152 @@ +# Browser-side SAP signing + +Apple gated the authenticate endpoint behind a SAP signature in August 2026. +The bag says so directly — `urlBag.sign-sap-request` lists `MZFinance: +authenticate`, `auth/v1: native` and `auth/v1/native: fast` — and every one of +those endpoints now answers an unsigned request with `403` and an empty body, +about 6ms in, before it looks at credentials. + +ipatool 2.4.0 solved this by running Apple's own signing code from a 2013 OS X +release under a CPU emulator. This is a port of that approach to the browser, +so the signature is produced client-side and the server keeps knowing nothing +about Apple credentials. + +The binaries it runs are fetched from Apple's software update CDN the first +time anyone signs in, and kept in the backend's data directory afterwards, so +a fresh deployment needs nothing done to it by hand. + +## Where it stands + +The signer works, and Apple accepts what it produces. Sending the same +deliberately wrong credentials with and without a signature separates the two +cases cleanly: + +``` +without signature: HTTP 403, empty body rejected at the edge +with signature: HTTP 200, 326 bytes, a plist credentials evaluated + customerMessage: MZFinance.BadLogin.Configurator_message +``` + +That second response is the ordinary "wrong password" answer, which is the +point: the request got as far as the credential check. Setup itself completes +against Apple too — the certificate comes from s.mzstatic.com and the setup +buffer round-trips through fpinit.itunes.apple.com, both HTTP 200. + +It is slow. Measured end to end in Chrome, driving the worker the way sign-in +does: + +``` +assets 35 ms (local disk; ~38 MB over the network on a cold cache) +setup 115 s 5.3M guest instructions for initialize, ~5M for the + two exchange rounds +signing 12 s per signature +``` + +Node runs the same setup in 63 s, so Chrome is roughly twice as slow. WebKit +is much faster than either — 35 s for setup and 3.5 s per signature, measured +with Playwright's WebKit under an iPhone 15 profile — so Safari users get a +first sign-in in about forty seconds where Chrome users wait two minutes. + +Setup happens once per session and a signature once per sign-in attempt. Both +run in a Web Worker and both report progress; neither is fast enough anywhere +to go quiet. + +Nothing in setup involves credentials. The only identity is the hardware id, +which is the same guid the client already sends in the clear, so the setup can +happen well before anyone types a password. + +It runs on WebKit, which is what matters for iOS. `tools/webkit-check.mjs` +drives the signer through Playwright's WebKit under an iPhone 15 profile and +gets a 501-byte signature, with WebAssembly, dedicated workers, the Cache API +and BigInt all behaving: + +``` +capability: webassembly ✓ worker ✓ caches ✓ bigint ✓ +setup 34.9 s, signing 3.5 s, signature 501 bytes +``` + +What that does not settle is memory on an actual phone. Measuring the +worker's footprint directly needs `measureUserAgentSpecificMemory` and so +cross-origin isolation, which this app does not have, but the guest mapping +is 144 MB and the assets another 38, so it is on the order of 200 MB. A +desktop WebKit has far more headroom than an iPhone, so the remaining risk is +a device killing the tab under memory pressure — which only a real device can +answer. + +## The block splitter + +unicorn.js cannot execute a basic block beyond a certain length. Synthetic +blocks of `nop` and of `mov rax,rcx` both survive 88 instructions and both +trap at 96, despite differing three-fold in bytes, so the limit counts +interpreter operations rather than bytes: + +``` +TODO .../unicorn/qemu/tcg/tci.c:1272: tcg_qemu_tb_exec_x86_64() +Aborted() +``` + +WebAssembly cannot emit native code, so this build runs QEMU's Tiny Code +Interpreter rather than the usual JIT, and `tci.c` aborts on an unimplemented +path. The guest is full of blocks over that length — the one `initialize` +calls into is 134 straight-line moves — and the emulator offers no way to +bound them: `uc_ctl` is the one entry point unicorn.js does not export, and it +only sizes the TCG buffer anyway. + +So `machine.ts` splits blocks itself. A HLT goes in at a safe boundary, the +guest stops on it, the original byte goes back, and execution resumes from the +same address. Only the translator can tell. + +Three things this requires: + +**Boundaries.** `length.ts` decodes the x86-64 encoding structure without +caring what any instruction does. objdump cannot supply them: its linear sweep +desyncs on these obfuscated images and reports valid instructions as bad +opcodes, and a HLT planted at a boundary taken from that output manufactures +an invalid opcode — which traps exactly like the bug being chased. + +**One block at a time.** Taking a branch also translates its target, so +letting execution chain hands the emulator a block of the guest's choosing. +The target's split has to be planted before the branch runs, which means +resolving the branch first: relative displacements come out of the encoding, +an indirect call through a jump table needs its ModRM and SIB evaluated +against live registers, and a return reads its target off the stack. + +**A split must not land on the instruction about to run.** The back edge of a +nine-instruction loop does exactly that whenever the limit is eight. + +The limit is 32. Measured over a full setup: 32 works and takes 63s, 48 works +but takes 76s, and 64 traps. + +## Layout + +| File | Role | +| --- | --- | +| `macho.ts` | Mach-O loader: x86-64 slice, segments, symbols, dyld rebase and bind opcodes | +| `length.ts` | x86-64 length decoder and basic-block measurement | +| `engine.ts` | unicorn.js wrapper, matching ipatool's `internal/sap/unicorn` | +| `shims.ts` | Guest service area, calling convention, heap allocator | +| `platform.ts` | The macOS imports the guest expects: CoreFoundation, IOKit, dlopen, `_read` for CoreFP.icxs | +| `machine.ts` | Loads the images, splits blocks, drives initialize / exchange / sign / teardown | +| `signer.ts` | The setup protocol and the signing entry point | + +## Notes for whoever picks this up + +Four bugs cost real time, all in the same shape — a length computed one byte +wrong, which plants a HLT mid-instruction, which traps exactly like the +emulator limit: + +- Segment offsets are `uint64` in the original and linkers rely on the wrap. + CoreFP encodes a backward jump of `-0x938` as `ADD_ADDR_ULEB + 0xfffffffffffff6c8`; BigInt has no width, so every offset step masks to 64 + bits. +- `BIND_OPCODE_DONE` means different things per stream: it separates one + symbol's sequence from the next in a lazy stream, but ends the regular and + weak streams, which are followed by padding that must not be parsed. +- `SHLD`/`SHRD` by an immediate (`0F A4`, `0F AC`) carry an imm8. The guest's + obfuscation is full of them. +- `Jcc rel32` (`0F 80`–`0F 8F`) carries no ModRM byte. + +The emulator, not objdump, is the ground truth for boundaries: if an executed +address is one the decoder would not have produced, the decoder is wrong. +`Shims.trace`, `Machine.traceInstructions` and `Machine.setUnmappedTrace` are +there for exactly that, since faults arrive without addresses. diff --git a/frontend/src/apple/sap/assets.ts b/frontend/src/apple/sap/assets.ts new file mode 100644 index 00000000..2a0cb0ff --- /dev/null +++ b/frontend/src/apple/sap/assets.ts @@ -0,0 +1,173 @@ +// Fetches the Apple binaries the SAP signer runs under emulation. +// +// They total about 38 MB and never change — they come from a 2013 OS X +// release — so they are cached and reused. The backend serves them from its +// data directory, and fetches them from Apple the first time they are asked +// for, so a fresh deployment needs nothing done to it by hand. + +import type { AssetBundle } from "./machine"; + +const CACHE_NAME = "sap-assets-v2"; + +const FILES = { + commerceKit: "CommerceKit", + commerceCore: "CommerceCore", + coreFP: "CoreFP", + coreFPICXS: "CoreFP.icxs", +} as const; + +export interface AssetProgress { + name: string; + loaded: number; + total: number; +} + +interface AssetStatus { + ready: boolean; + fetching: boolean; + missing: string[]; + progress: { stage: string; found: string[] } | null; + error: string | null; +} + +const FETCH_POLL_MS = 3000; +const FETCH_TIMEOUT_MS = 10 * 60 * 1000; + +async function status(headers: Record): Promise { + const response = await fetch("/api/sap/assets", { headers }); + if (!response.ok) throw new Error("cannot reach the SAP asset service"); + return response.json(); +} + +/** + * Makes sure the backend has the assets, asking it to fetch them from Apple + * if not. They land in its data directory and stay there, so this is a + * one-off on a fresh deployment rather than something every visitor pays. + */ +async function ensureInstalled( + headers: Record, + onProgress?: (progress: AssetProgress) => void, +): Promise { + let state = await status(headers); + if (state.ready) return; + + if (!state.fetching) { + const response = await fetch("/api/sap/assets/fetch", { + method: "POST", + headers, + }); + if (!response.ok) { + throw new Error("the server could not start fetching the SAP assets"); + } + } + + const deadline = Date.now() + FETCH_TIMEOUT_MS; + for (;;) { + if (Date.now() > deadline) { + throw new Error("timed out waiting for the server to fetch the SAP assets"); + } + + await new Promise((resolve) => setTimeout(resolve, FETCH_POLL_MS)); + state = await status(headers); + + if (state.ready) return; + if (state.error) throw new Error(state.error); + + // Report as an asset-shaped step so the caller has one progress channel. + const found = state.progress?.found.length ?? 0; + onProgress?.({ + name: state.progress?.stage ?? "server", + loaded: found, + total: 4, + }); + } +} + +/** Reports whether the backend has the assets, without downloading them. */ +export async function assetsReady( + headers: Record = {}, +): Promise { + try { + const response = await fetch("/api/sap/assets", { headers }); + if (!response.ok) return false; + return Boolean((await response.json()).ready); + } catch { + return false; + } +} + +async function fetchAsset( + name: string, + headers: Record, + onProgress?: (progress: AssetProgress) => void, +): Promise { + const url = `/api/sap/assets/${name}`; + + // The Cache API is unavailable on insecure origins, so treat it as an + // optimisation rather than a requirement. + let cache: Cache | null = null; + try { + cache = await caches.open(CACHE_NAME); + const hit = await cache.match(url); + if (hit) { + const bytes = new Uint8Array(await hit.arrayBuffer()); + onProgress?.({ name, loaded: bytes.length, total: bytes.length }); + return bytes; + } + } catch { + cache = null; + } + + const response = await fetch(url, { headers }); + if (!response.ok) { + const detail = await response.json().catch(() => ({})); + throw new Error(detail.error ?? `failed to fetch SAP asset ${name}`); + } + + try { + await cache?.put(url, response.clone()); + } catch { + // A full or unavailable cache only costs a re-download next time. + } + + const total = Number(response.headers.get("Content-Length") ?? 0); + if (!response.body || !onProgress) { + return new Uint8Array(await response.arrayBuffer()); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let loaded = 0; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + loaded += value.length; + onProgress({ name, loaded, total }); + } + + const bytes = new Uint8Array(loaded); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + + return bytes; +} + +export async function loadAssets( + headers: Record = {}, + onProgress?: (progress: AssetProgress) => void, +): Promise { + await ensureInstalled(headers, onProgress); + + const entries = await Promise.all( + Object.entries(FILES).map(async ([key, name]) => { + return [key, await fetchAsset(name, headers, onProgress)] as const; + }), + ); + + return Object.fromEntries(entries) as unknown as AssetBundle; +} diff --git a/frontend/src/apple/sap/client.ts b/frontend/src/apple/sap/client.ts new file mode 100644 index 00000000..f2f5ac41 --- /dev/null +++ b/frontend/src/apple/sap/client.ts @@ -0,0 +1,183 @@ +// Main-thread handle on the SAP signer worker. +// +// Apple began requiring a SAP signature on authenticate in August 2026, and +// producing one means emulating Apple's own signing code. Measured in Chrome +// on a laptop: setup takes about 115 seconds and each signature about 12. +// Setup happens once per session, a signature once per sign-in attempt, and +// both are slow enough to need saying so on screen. +// +// The signer is created lazily, so a session that never signs in never pays +// for it, and the assets are only fetched when they are about to be used. + +import { getAccessToken } from "../../components/Auth/PasswordGate"; +import { useSapStore } from "../../store/sap"; +import type { AssetProgress } from "./assets"; +import type { WorkerRequest, WorkerResponse } from "./worker"; + +export type SetupProgress = + | { phase: "assets"; asset: AssetProgress } + | { phase: "setup" } + | { phase: "signing" }; + +const SETUP_TIMEOUT_MS = 15 * 60 * 1000; + +let worker: Worker | null = null; +let ready: Promise | null = null; +let preparedFor: string | null = null; +let nextId = 1; + +const store = () => useSapStore.getState(); + +const pending = new Map< + number, + { resolve: (signature: Uint8Array) => void; reject: (error: Error) => void } +>(); + +let onProgress: ((progress: SetupProgress) => void) | null = null; +let settleSetup: { resolve: () => void; reject: (error: Error) => void } | null = null; + +function handle(event: MessageEvent) { + const message = event.data; + + if (message.type === "progress") { + if (message.phase === "assets") { + const { loaded, total } = message.asset; + store().setAssets(total ? Math.round((loaded / total) * 100) : 0); + onProgress?.({ phase: "assets", asset: message.asset }); + } else { + store().setSetup(); + onProgress?.({ phase: "setup" }); + } + return; + } + + if (message.type === "ready") { + store().setReady(); + settleSetup?.resolve(); + settleSetup = null; + return; + } + + if (message.type === "signed") { + pending.get(message.id)?.resolve(message.signature); + pending.delete(message.id); + return; + } + + const error = new Error(message.message); + store().setError(error.message); + + if (message.id !== undefined) { + pending.get(message.id)?.reject(error); + pending.delete(message.id); + return; + } + + settleSetup?.reject(error); + settleSetup = null; + // A failed setup leaves nothing usable, so let the next attempt start over. + reset(error); +} + +function reset(error: Error) { + for (const waiter of pending.values()) waiter.reject(error); + pending.clear(); + + worker?.terminate(); + worker = null; + ready = null; + preparedFor = null; + store().setError(error.message); +} + +/** + * Prepares the signer, reusing it if one is already set up for this hardware + * id. Safe to call more than once; concurrent callers share the same setup. + * + * A signer is bound to the hardware id it was initialised with, so switching + * accounts means building a new one rather than signing with the wrong + * identity. + */ +export function prepareSigner( + hardwareID: string, + progress?: (progress: SetupProgress) => void, +): Promise { + onProgress = progress ?? null; + + if (ready && preparedFor === hardwareID) return ready; + if (ready) reset(new Error("SAP signer rebuilt for a different device")); + + preparedFor = hardwareID; + store().begin(hardwareID); + + worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }); + worker.onmessage = handle; + worker.onerror = (event) => { + reset(new Error(event.message || "SAP signer worker failed")); + }; + + // Without this a worker that wedges — a stalled fetch, an emulator that + // never returns — leaves the caller waiting forever with nothing on screen + // and no request ever going out. Generous, because a cold deployment has to + // fetch 38 MB from Apple before it can even start. + ready = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error("timed out preparing the SAP signer")); + reset(new Error("timed out preparing the SAP signer")); + }, SETUP_TIMEOUT_MS); + + settleSetup = { + resolve: () => { + clearTimeout(timer); + resolve(); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }; + }); + + const request: WorkerRequest = { + type: "setup", + hardwareID: hexToBytes(hardwareID), + // sessionStorage exists only here, not in the worker. + accessToken: getAccessToken(), + }; + worker.postMessage(request); + + return ready; +} + +/** Signs a request body. The signer must have been prepared first. */ +export function signAction(payload: Uint8Array): Promise { + if (!worker || !ready) { + return Promise.reject(new Error("SAP signer is not prepared")); + } + + return ready.then( + () => + new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + + const request: WorkerRequest = { type: "sign", id, payload }; + worker!.postMessage(request, [payload.buffer]); + }), + ); +} + +/** The guid is hex; the signer wants the bytes behind it. */ +function hexToBytes(hex: string): Uint8Array { + const clean = hex.replace(/[^0-9a-fA-F]/g, ""); + if (clean.length === 0 || clean.length % 2 !== 0 || clean.length > 40) { + throw new Error("device identifier must be 1 to 20 hex-encoded bytes"); + } + + const bytes = new Uint8Array(clean.length / 2); + for (let index = 0; index < bytes.length; index++) { + bytes[index] = parseInt(clean.slice(index * 2, index * 2 + 2), 16); + } + + return bytes; +} diff --git a/frontend/src/apple/sap/engine.ts b/frontend/src/apple/sap/engine.ts new file mode 100644 index 00000000..7a6d3287 --- /dev/null +++ b/frontend/src/apple/sap/engine.ts @@ -0,0 +1,197 @@ +// Thin wrapper over unicorn.js, matching the surface ipatool's +// internal/sap/unicorn exposes. +// +// ipatool loads libunicorn through purego and calls fourteen uc_* entry +// points. unicorn.js is the same engine (2.1.4) built to WebAssembly, and it +// provides all of them except uc_ctl, which is only used on Windows to shrink +// the TCG buffer and is a no-op everywhere else. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UnicornModule = any; + +let modulePromise: Promise | null = null; + +/** Loads the x86 build of unicorn.js once per page. */ +async function loadUnicorn(): Promise { + if (!modulePromise) { + modulePromise = import("@alexaltea/unicorn-js/x86").then( + (module: { default: () => Promise }) => module.default(), + ); + } + return modulePromise; +} + +export interface CodeHook { + remove(): void; +} + +export class Engine { + private readonly uc: UnicornModule; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly engine: any; + private closed = false; + + readonly regRDI: number; + readonly regRSI: number; + readonly regRDX: number; + readonly regRCX: number; + readonly regR8: number; + readonly regR9: number; + readonly regRSP: number; + readonly regRAX: number; + readonly regRIP: number; + + private constructor(uc: UnicornModule) { + this.uc = uc; + this.engine = new uc.Unicorn(uc.ARCH_X86, uc.MODE_64); + + this.regRDI = uc.X86_REG_RDI; + this.regRSI = uc.X86_REG_RSI; + this.regRDX = uc.X86_REG_RDX; + this.regRCX = uc.X86_REG_RCX; + this.regR8 = uc.X86_REG_R8; + this.regR9 = uc.X86_REG_R9; + this.regRSP = uc.X86_REG_RSP; + this.regRAX = uc.X86_REG_RAX; + this.regRIP = uc.X86_REG_RIP; + } + + static async create(): Promise { + return new Engine(await loadUnicorn()); + } + + memMap(address: bigint, size: bigint): void { + this.engine.mem_map(address, Number(size), this.uc.PROT_ALL); + } + + memWrite(address: bigint, data: Uint8Array): void { + if (data.length === 0) return; + this.engine.mem_write(address, data); + } + + memRead(address: bigint, size: number): Uint8Array { + return this.engine.mem_read(address, size); + } + + memZero(address: bigint, size: number): void { + if (size === 0) return; + this.engine.mem_write(address, new Uint8Array(size)); + } + + /** Reads a general-purpose register by its x86 encoding number, 0 to 15. */ + gpr(index: number): bigint { + const order = [ + this.uc.X86_REG_RAX, this.uc.X86_REG_RCX, this.uc.X86_REG_RDX, + this.uc.X86_REG_RBX, this.uc.X86_REG_RSP, this.uc.X86_REG_RBP, + this.uc.X86_REG_RSI, this.uc.X86_REG_RDI, this.uc.X86_REG_R8, + this.uc.X86_REG_R9, this.uc.X86_REG_R10, this.uc.X86_REG_R11, + this.uc.X86_REG_R12, this.uc.X86_REG_R13, this.uc.X86_REG_R14, + this.uc.X86_REG_R15, + ]; + return this.regRead(order[index]); + } + + regRead(register: number): bigint { + return BigInt(this.engine.reg_read_i64(register)) & ((1n << 64n) - 1n); + } + + regWrite(register: number, value: bigint): void { + this.engine.reg_write_i64(register, BigInt.asIntN(64, value)); + } + + readUint32(address: bigint): number { + const data = this.memRead(address, 4); + return new DataView(data.buffer, data.byteOffset, 4).getUint32(0, true); + } + + readUint64(address: bigint): bigint { + const data = this.memRead(address, 8); + return new DataView(data.buffer, data.byteOffset, 8).getBigUint64(0, true); + } + + writeUint32(address: bigint, value: number): void { + const data = new Uint8Array(4); + new DataView(data.buffer).setUint32(0, value >>> 0, true); + this.memWrite(address, data); + } + + writeUint64(address: bigint, value: bigint): void { + const data = new Uint8Array(8); + new DataView(data.buffer).setBigUint64(0, value & ((1n << 64n) - 1n), true); + this.memWrite(address, data); + } + + /** Installs a UC_HOOK_CODE over [begin, end], the only hook kind SAP needs. */ + addCodeHook( + begin: bigint, + end: bigint, + callback: (address: bigint) => void, + ): CodeHook { + const handle = this.engine.hook_add( + this.uc.HOOK_CODE, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_handle: any, address: bigint | number) => { + callback(typeof address === "bigint" ? address : BigInt(address)); + }, + {}, + begin, + end, + ); + + return { + remove: () => { + this.engine.hook_del(handle); + }, + }; + } + + /** + * Reports guest accesses to unmapped memory. ipatool never needs this: the + * Go build surfaces them as UC_ERR_READ_UNMAPPED from uc_emu_start, while + * this WebAssembly build traps instead, losing the address. Catching them + * here keeps the fault diagnosable. + */ + addUnmappedHook( + callback: (kind: string, address: bigint, size: number) => void, + ): CodeHook { + const kinds: Array<[number, string]> = [ + [this.uc.HOOK_MEM_READ_UNMAPPED, "read"], + [this.uc.HOOK_MEM_WRITE_UNMAPPED, "write"], + [this.uc.HOOK_MEM_FETCH_UNMAPPED, "fetch"], + ]; + + const mask = kinds.reduce((total, [flag]) => total | flag, 0); + const handle = this.engine.hook_add( + mask, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_handle: any, type: number, address: bigint | number, size: number) => { + const kind = kinds.find(([flag]) => flag === type)?.[1] ?? String(type); + callback(kind, typeof address === "bigint" ? address : BigInt(address), size); + return false; + }, + {}, + 1n, + 0n, + ); + + return { + remove: () => { + this.engine.hook_del(handle); + }, + }; + } + + start(begin: bigint, end: bigint, timeoutMicros: bigint, instructionLimit: number): void { + this.engine.emu_start(begin, end, timeoutMicros, instructionLimit); + } + + stop(): void { + this.engine.emu_stop(); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.engine.close(); + } +} diff --git a/frontend/src/apple/sap/length.ts b/frontend/src/apple/sap/length.ts new file mode 100644 index 00000000..cb467708 --- /dev/null +++ b/frontend/src/apple/sap/length.ts @@ -0,0 +1,388 @@ +// x86-64 instruction length decoder. +// +// unicorn.js traps on any basic block longer than about ninety instructions, +// so the machine splits long blocks by planting a temporary HLT at a safe +// point ahead of the guest and continuing from there. That needs instruction +// boundaries, and the guest images are obfuscated badly enough that objdump's +// linear sweep desyncs and reports valid instructions as bad opcodes. +// +// Only lengths are needed, never semantics, so this decodes the encoding +// structure — prefixes, REX, opcode, ModRM, SIB, displacement, immediate — +// and does not care what any instruction does. It also reports whether an +// instruction ends a basic block, since the emulator will end one there +// anyway and there is no point planting a HLT past it. + +const enum Imm { + None = 0, + Byte = 1, + Word = 2, + /** 4 bytes, or 2 with an operand-size prefix. */ + Zed = 3, + /** 4 bytes, or 8 for a REX.W MOV. */ + Vee = 4, +} + +/** Opcodes taking a ModRM byte, one bit per opcode. */ +const ONE_BYTE_MODRM = new Uint8Array(256); +for (const range of [ + [0x00, 0x03], [0x08, 0x0b], [0x10, 0x13], [0x18, 0x1b], + [0x20, 0x23], [0x28, 0x2b], [0x30, 0x33], [0x38, 0x3b], + [0x62, 0x63], [0x69, 0x69], [0x6b, 0x6b], [0x80, 0x8f], + [0xc0, 0xc1], [0xc4, 0xc7], [0xd0, 0xd3], [0xd8, 0xdf], + [0xf6, 0xf7], [0xfe, 0xff], +]) { + for (let code = range[0]; code <= range[1]; code++) ONE_BYTE_MODRM[code] = 1; +} + +const ONE_BYTE_IMMEDIATE = new Uint8Array(256); +function setImmediate(from: number, to: number, kind: Imm) { + for (let code = from; code <= to; code++) ONE_BYTE_IMMEDIATE[code] = kind; +} +for (const base of [0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38]) { + setImmediate(base + 4, base + 4, Imm.Byte); // AL, imm8 + setImmediate(base + 5, base + 5, Imm.Zed); // eAX, imm +} +setImmediate(0x68, 0x68, Imm.Zed); +setImmediate(0x69, 0x69, Imm.Zed); +setImmediate(0x6a, 0x6a, Imm.Byte); +setImmediate(0x6b, 0x6b, Imm.Byte); +setImmediate(0x70, 0x7f, Imm.Byte); // Jcc rel8 +setImmediate(0x80, 0x80, Imm.Byte); +setImmediate(0x81, 0x81, Imm.Zed); +setImmediate(0x83, 0x83, Imm.Byte); +setImmediate(0xa8, 0xa8, Imm.Byte); +setImmediate(0xa9, 0xa9, Imm.Zed); +setImmediate(0xb0, 0xb7, Imm.Byte); // MOV r8, imm8 +setImmediate(0xb8, 0xbf, Imm.Vee); // MOV r, imm +setImmediate(0xc0, 0xc1, Imm.Byte); +setImmediate(0xc2, 0xc2, Imm.Word); +setImmediate(0xc6, 0xc6, Imm.Byte); +setImmediate(0xc7, 0xc7, Imm.Zed); +setImmediate(0xc8, 0xc8, Imm.Word); // ENTER takes imm16 then imm8 +setImmediate(0xcd, 0xcd, Imm.Byte); +setImmediate(0xe8, 0xe9, Imm.Zed); // CALL/JMP rel32 +setImmediate(0xe0, 0xe7, Imm.Byte); +setImmediate(0xeb, 0xeb, Imm.Byte); + +/** Two-byte (0x0F) opcodes that take no ModRM byte. */ +const TWO_BYTE_NO_MODRM = new Set([ + 0x05, 0x06, 0x07, 0x08, 0x09, 0x0b, 0x0e, 0x30, 0x31, 0x32, 0x33, 0x34, + 0x35, 0x37, 0x77, 0xa0, 0xa1, 0xa2, 0xa8, 0xa9, 0xaa, 0xc8, 0xc9, 0xca, + 0xcb, 0xcc, 0xcd, 0xce, 0xcf, +]); + +export interface Decoded { + length: number; + /** True for branches, calls, returns and interrupts. */ + endsBlock: boolean; + /** + * For a direct relative branch, its displacement from the end of the + * instruction. Absent for indirect branches and returns, whose target only + * exists at run time. + */ + relative?: number; + /** + * For an indirect branch through memory, how to compute the address its + * target pointer sits at. Register numbers are already REX-extended. + */ + indirect?: { + base: number | null; + index: number | null; + scale: number; + displacement: number; + /** True when the address is relative to the end of the instruction. */ + ripRelative: boolean; + /** True for `call rax` style, where the register holds the target. */ + register: number | null; + }; +} + +/** + * Decodes the instruction at `offset`, returning its length in bytes. + * Throws when the encoding is not recognised, which the caller should treat + * as "stop scanning" rather than as a fatal error. + */ +export function decode(code: Uint8Array, offset: number): Decoded { + let cursor = offset; + let operandSize = false; + let addressSize = false; + let rexW = false; + let rexB = false; + let rexX = false; + + // Legacy prefixes, then REX. Anything else ends the prefix run. + for (;;) { + const byte = code[cursor]; + if (byte === undefined) throw new Error("instruction runs past the buffer"); + + if (byte === 0x66) { + operandSize = true; + } else if (byte === 0x67) { + addressSize = true; + } else if ( + byte === 0xf0 || byte === 0xf2 || byte === 0xf3 || + byte === 0x2e || byte === 0x36 || byte === 0x3e || + byte === 0x26 || byte === 0x64 || byte === 0x65 + ) { + // lock, rep, segment overrides: no effect on length + } else if (byte >= 0x40 && byte <= 0x4f) { + rexW = (byte & 0x08) !== 0; + rexX = (byte & 0x02) !== 0; + rexB = (byte & 0x01) !== 0; + cursor++; + break; // REX must be the last prefix + } else { + break; + } + cursor++; + } + + let opcode = code[cursor++]; + let indirect: Decoded["indirect"]; + let hasModRM: boolean; + let immediate: Imm = Imm.None; + let endsBlock = false; + + if (opcode === 0x0f) { + const second = code[cursor++]; + + if (second === 0x38 || second === 0x3a) { + // Three-byte opcodes: always ModRM, 0x3a adds an imm8. + cursor++; + hasModRM = true; + if (second === 0x3a) immediate = Imm.Byte; + } else { + hasModRM = !TWO_BYTE_NO_MODRM.has(second); + + if (second >= 0x80 && second <= 0x8f) { + // Jcc rel32 carries no ModRM; counting one shifts every later + // boundary by a byte. + hasModRM = false; + immediate = Imm.Zed; + endsBlock = true; + } else if ( + // pshuf* and the SSE compares + second === 0x70 || second === 0xc2 || second === 0xc4 || + second === 0xc5 || second === 0xc6 || + // shift groups 12 to 14, taking a count byte + second === 0x71 || second === 0x72 || second === 0x73 || + // bit test group 8 + second === 0xba || + // SHLD and SHRD by an immediate; the guest's obfuscation is full of + // these and a missed byte here shifts every later boundary + second === 0xa4 || second === 0xac + ) { + immediate = Imm.Byte; + } else if (second === 0x05 || second === 0x0b) { + endsBlock = true; // SYSCALL, UD2 + } + } + opcode = -1; + } else { + hasModRM = ONE_BYTE_MODRM[opcode] === 1; + immediate = ONE_BYTE_IMMEDIATE[opcode] as Imm; + + endsBlock = + (opcode >= 0x70 && opcode <= 0x7f) || // Jcc rel8 + opcode === 0xc2 || opcode === 0xc3 || // RET + opcode === 0xca || opcode === 0xcb || + opcode === 0xcc || opcode === 0xcd || opcode === 0xce || // INT + opcode === 0xcf || // IRET + (opcode >= 0xe0 && opcode <= 0xe3) || // LOOP/JCXZ + opcode === 0xe8 || opcode === 0xe9 || opcode === 0xeb || // CALL/JMP + opcode === 0xf1 || opcode === 0xf4; // INT1, HLT + } + + if (hasModRM) { + const modrm = code[cursor++]; + if (modrm === undefined) throw new Error("ModRM runs past the buffer"); + + const mod = modrm >> 6; + const rm = modrm & 0x07; + + let sibByte: number | null = null; + let displacement = 0; + let displacementAt = -1; + + if (mod !== 3) { + if (addressSize) { + // 16-bit addressing: only mod 0 with rm 6 carries a displacement. + if (mod === 1) cursor += 1; + else if (mod === 2 || (mod === 0 && rm === 6)) cursor += 2; + } else { + if (rm === 4) { + sibByte = code[cursor++]; + if (sibByte === undefined) throw new Error("SIB runs past the buffer"); + // A base of 5 with mod 0 means a 32-bit displacement instead. + if (mod === 0 && (sibByte & 0x07) === 5) { + displacementAt = cursor; + cursor += 4; + } + } + if (mod === 1) { + displacementAt = cursor; + cursor += 1; + } else if (mod === 2) { + displacementAt = cursor; + cursor += 4; + } else if (mod === 0 && rm === 5) { + displacementAt = cursor; // RIP-relative + cursor += 4; + } + } + } + + // FF /2 and /3 are indirect CALL, /4 and /5 indirect JMP. + if (opcode === 0xff) { + const reg = (modrm >> 3) & 0x07; + if (reg >= 2 && reg <= 5) { + endsBlock = true; + + const view = new DataView(code.buffer, code.byteOffset, code.byteLength); + if (displacementAt >= 0) { + displacement = + mod === 1 ? view.getInt8(displacementAt) : view.getInt32(displacementAt, true); + } + + if (mod === 3) { + indirect = { + base: null, index: null, scale: 1, displacement: 0, + ripRelative: false, register: rm | (rexB ? 8 : 0), + }; + } else if (sibByte !== null) { + const scale = 1 << (sibByte >> 6); + const indexReg = ((sibByte >> 3) & 0x07) | (rexX ? 8 : 0); + const baseReg = (sibByte & 0x07) | (rexB ? 8 : 0); + indirect = { + // Index 4 without REX.X encodes "no index". + index: indexReg === 4 ? null : indexReg, + base: mod === 0 && (sibByte & 0x07) === 5 ? null : baseReg, + scale, displacement, ripRelative: false, register: null, + }; + } else { + indirect = { + base: mod === 0 && rm === 5 ? null : rm | (rexB ? 8 : 0), + index: null, scale: 1, displacement, + ripRelative: mod === 0 && rm === 5, + register: null, + }; + } + } + } + + // Group 3: TEST under F6/F7 carries an immediate, the rest do not. + if (opcode === 0xf6 || opcode === 0xf7) { + const reg = (modrm >> 3) & 0x07; + immediate = reg <= 1 ? (opcode === 0xf6 ? Imm.Byte : Imm.Zed) : Imm.None; + } + } + + switch (immediate) { + case Imm.Byte: + cursor += 1; + break; + case Imm.Word: + cursor += 2; + break; + case Imm.Zed: + cursor += operandSize ? 2 : 4; + break; + case Imm.Vee: + cursor += rexW ? 8 : operandSize ? 2 : 4; + break; + default: + break; + } + + if (opcode === 0xc8) cursor += 1; // ENTER's trailing imm8 + + const length = cursor - offset; + if (length <= 0 || length > 15) { + throw new Error(`implausible instruction length ${length}`); + } + + return { + length, + endsBlock, + relative: relativeTarget(code, offset, cursor), + indirect, + }; +} + +/** Displacement of a direct relative branch, measured from its end. */ +function relativeTarget( + code: Uint8Array, + offset: number, + end: number, +): number | undefined { + const first = code[offset]; + + // Skip prefixes to find the opcode; only REX matters for these encodings. + let cursor = offset; + while ( + code[cursor] === 0x66 || code[cursor] === 0x67 || code[cursor] === 0xf0 || + code[cursor] === 0xf2 || code[cursor] === 0xf3 || + (code[cursor] >= 0x40 && code[cursor] <= 0x4f) + ) { + cursor++; + } + + const opcode = code[cursor]; + const view = new DataView(code.buffer, code.byteOffset, code.byteLength); + + if (opcode === 0xeb || (opcode >= 0x70 && opcode <= 0x7f)) { + return view.getInt8(cursor + 1); + } + + if (opcode === 0xe8 || opcode === 0xe9) { + return view.getInt32(cursor + 1, true); + } + + if (opcode === 0x0f && code[cursor + 1] >= 0x80 && code[cursor + 1] <= 0x8f) { + return view.getInt32(cursor + 2, true); + } + + void first; + void end; + return undefined; +} + +export interface Block { + /** Instructions up to and including the terminator, or up to `limit`. */ + instructions: number; + /** Byte offset one past the last instruction counted. */ + end: number; + /** False when the scan stopped at `limit` rather than at a terminator. */ + complete: boolean; + /** The terminator and where it starts, when the block ended on its own. */ + terminator?: { offset: number; decoded: Decoded }; +} + +/** + * Measures the basic block starting at `offset`, stopping at `limit` + * instructions if it has not ended by then. + */ +export function measureBlock( + code: Uint8Array, + offset: number, + limit: number, +): Block { + let cursor = offset; + + for (let index = 0; index < limit; index++) { + const start = cursor; + const decoded = decode(code, cursor); + cursor += decoded.length; + + if (decoded.endsBlock) { + return { + instructions: index + 1, + end: cursor, + complete: true, + terminator: { offset: start, decoded }, + }; + } + } + + return { instructions: limit, end: cursor, complete: false }; +} diff --git a/frontend/src/apple/sap/machine.ts b/frontend/src/apple/sap/machine.ts new file mode 100644 index 00000000..cafc7e79 --- /dev/null +++ b/frontend/src/apple/sap/machine.ts @@ -0,0 +1,594 @@ +// The SAP guest machine. +// +// Ported from ipatool's internal/sap/machine/machine.go. Loads the three +// CommerceKit images into an emulated x86-64 address space, wires the shim +// area underneath them, and exposes the four entry points the SAP protocol +// drives: initialize, exchange, sign and teardown. +// +// The entry points are obfuscated symbol names in the shipped binaries, so +// they are looked up by those names rather than anything descriptive. + +import { Engine } from "./engine"; +import { type Block, type Decoded, measureBlock } from "./length"; +import { MachImage } from "./macho"; +import { registerPlatformServices } from "./platform"; +import { HEAP_BASE, HEAP_SIZE, Shims, align } from "./shims"; + +const RETURN_ADDRESS = 0x0000000100000000n; +const CORE_FP_BASE = 0x0000100000000000n; +const COMMERCE_BASE = 0x0000100040000000n; +const KIT_BASE = 0x0000100080000000n; +const SCRATCH_BASE = 0x0000300000000000n; +const SCRATCH_SIZE = 32n << 20n; +const STACK_BASE = 0x0000500000000000n; +const STACK_SIZE = 8n << 20n; +const STACK_END = STACK_BASE + STACK_SIZE; +const PAGE_SIZE = 0x1000n; +const MAX_OUTPUT_SIZE = 16n << 20n; + +// ipatool bounds a guest call by both wall clock and instruction count. A +// non-zero timeout makes Unicorn spawn a timer thread, which the WebAssembly +// build cannot do ("qemu_thread_create: Not supported"), so the instruction +// limit is the only bound here. It is the tighter of the two in practice. +const EXECUTION_TIMEOUT_MICROS = 0n; +const INSTRUCTION_LIMIT = 100_000_000; + +// unicorn.js aborts inside QEMU's Tiny Code Interpreter on a long basic +// block, so long blocks are executed in pieces: a HLT is planted this far +// ahead of the guest, and execution resumes from there once it stops. +// +// Synthetic blocks of one-byte instructions survive to eighty-eight, but the +// guest's are wider and generate more interpreter operations each, so the +// usable figure is lower. Measured over a full SAP setup: thirty-two works +// and takes 63s, forty-eight works but takes 76s, sixty-four traps. +const MAX_BLOCK_INSTRUCTIONS = 32; + +// Enough bytes to decode MAX_BLOCK_INSTRUCTIONS of any encoding. +const CODE_WINDOW = MAX_BLOCK_INSTRUCTIONS * 15; + +const CORE_EXPORT_NAMES = [ + "_WIn9UJ86JKdV4dM", + "_X46O5IeS", + "_YlCJ3lg", + "_dku592fbFAj", + "_fdjkDSAFjklaf2s", + "_lxpgvVMLd0S7uRl", +]; + +const ENTRY_NAMES = { + initialize: "_cp2g1b9ro", + exchange: "_Mib5yocT", + sign: "_Fc3vhtJDvr", + teardown: "_IPaI1oem5iL", + dispose: "_jEHf8Xzsv8K", +} as const; + +export interface AssetBundle { + commerceKit: Uint8Array; + commerceCore: Uint8Array; + coreFP: Uint8Array; + coreFPICXS: Uint8Array; +} + +interface EntryPoints { + initialize: bigint; + exchange: bigint; + sign: bigint; + teardown: bigint; + dispose: bigint; +} + +export class Machine { + private readonly engine: Engine; + private readonly shims: Shims; + private readonly entry: EntryPoints; + + private scratchCursor = 0n; + private closed = false; + + private constructor(engine: Engine, shims: Shims, entry: EntryPoints) { + this.engine = engine; + this.shims = shims; + this.entry = entry; + } + + static async open(bundle: AssetBundle): Promise { + const coreFP = new MachImage("CoreFP", bundle.coreFP); + const commerceCore = new MachImage("CommerceCore", bundle.commerceCore); + const commerceKit = new MachImage("CommerceKit", bundle.commerceKit); + + const exports = new Map(); + const coreExports = new Map(); + + for (const name of CORE_EXPORT_NAMES) { + const address = coreFP.export(name, CORE_FP_BASE); + exports.set(name, address); + coreExports.set(name, address); + } + + exports.set( + "_get_mac_address", + commerceCore.export("_get_mac_address", COMMERCE_BASE), + ); + + const entry = {} as EntryPoints; + for (const [role, symbol] of Object.entries(ENTRY_NAMES)) { + const address = commerceKit.export(symbol, KIT_BASE); + exports.set(symbol, address); + entry[role as keyof EntryPoints] = address; + } + + const engine = await Engine.create(); + + for (const [address, size] of [ + [RETURN_ADDRESS, PAGE_SIZE], + [SCRATCH_BASE, SCRATCH_SIZE], + [HEAP_BASE, HEAP_SIZE], + [STACK_BASE, STACK_SIZE], + ] as const) { + engine.memMap(address, size); + } + + // A lone HLT the guest returns into, so a finished call stops the engine. + engine.memWrite(RETURN_ADDRESS, new Uint8Array([0xf4])); + + const shims = new Shims(engine); + registerPlatformServices(shims, engine, coreExports, bundle.coreFPICXS); + shims.installHook(); + + const resolve = (name: string): bigint => + exports.get(name) ?? shims.resolve(name); + + for (const [image, base] of [ + [coreFP, CORE_FP_BASE], + [commerceCore, COMMERCE_BASE], + [commerceKit, KIT_BASE], + ] as const) { + image.relocate(base, resolve); + image.load(engine); + } + + return new Machine(engine, shims, entry); + } + + /** Starts a SAP session for a hardware identity and returns its context. */ + initialize(hardwareID: Uint8Array): bigint { + const hardware = hardwareBlock(hardwareID); + this.beginCall(); + + try { + const contextField = this.scratch(null, 8n); + const hardwareAddress = this.scratch(hardware, BigInt(hardware.length)); + + const status = this.invoke(this.entry.initialize, contextField, hardwareAddress); + if (asInt32(status) !== 0) { + throw new Error(`SAP initialization returned ${asInt32(status)}`); + } + + const context = this.engine.readUint64(contextField); + if (context === 0n) { + throw new Error("SAP initialization returned a null context"); + } + + return context; + } finally { + this.clearScratch(); + } + } + + /** Drives one round of the SAP setup handshake. */ + exchange( + version: number, + hardwareID: Uint8Array, + context: bigint, + input: Uint8Array, + ): { output: Uint8Array; state: number } { + const hardware = hardwareBlock(hardwareID); + this.beginCall(); + + try { + const hardwareAddress = this.scratch(hardware, BigInt(hardware.length)); + const inputAddress = this.scratch(input, BigInt(input.length)); + const outputField = this.scratch(null, 8n); + const lengthField = this.scratch(null, 8n); + const resultField = this.scratch(null, 4n); + + const status = this.invoke( + this.entry.exchange, + BigInt(version), + hardwareAddress, + context, + inputAddress, + BigInt(input.length), + outputField, + lengthField, + resultField, + ); + if (asInt32(status) !== 0) { + throw new Error(`SAP exchange returned ${asInt32(status)}`); + } + + const output = this.consumeOutput(outputField, lengthField); + return { output, state: this.engine.readUint32(resultField) | 0 }; + } finally { + this.clearScratch(); + } + } + + /** Signs a request payload once setup has completed. */ + sign(context: bigint, input: Uint8Array): Uint8Array { + this.beginCall(); + + try { + const inputAddress = this.scratch(input, BigInt(input.length)); + const outputField = this.scratch(null, 8n); + const lengthField = this.scratch(null, 8n); + + const status = this.invoke( + this.entry.sign, + context, + inputAddress, + BigInt(input.length), + outputField, + lengthField, + ); + if (asInt32(status) !== 0) { + throw new Error(`SAP signing returned ${asInt32(status)}`); + } + + return this.consumeOutput(outputField, lengthField); + } finally { + this.clearScratch(); + } + } + + teardown(context: bigint): void { + const status = this.invoke(this.entry.teardown, context); + if (asInt32(status) !== 0) { + throw new Error(`SAP teardown returned ${asInt32(status)}`); + } + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.engine.close(); + } + + /** Reports each guest service call; see Shims.trace. */ + setTrace(trace: ((name: string) => void) | null): void { + this.shims.trace = trace; + } + + /** + * Reports every instruction the guest reaches. Very slow, and only useful + * for finding where a run goes wrong: the guest is an opaque binary and + * this build reports an invalid opcode as a module trap with no address. + */ + traceInstructions(callback: (address: bigint) => void): void { + this.engine.addCodeHook(1n, 0n, callback); + } + + /** Reads a register by the names Engine exposes, for diagnostics. */ + register(name: "rax" | "rdi" | "rsi" | "rsp" | "rip"): bigint { + const map = { + rax: this.engine.regRAX, + rdi: this.engine.regRDI, + rsi: this.engine.regRSI, + rsp: this.engine.regRSP, + rip: this.engine.regRIP, + }; + return this.engine.regRead(map[name]); + } + + /** Reports guest accesses to unmapped memory; see Engine.addUnmappedHook. */ + setUnmappedTrace( + trace: (kind: string, address: bigint, size: number) => void, + ): void { + this.engine.addUnmappedHook(trace); + } + + /** Reads guest memory. For diagnosing a run against the Go implementation. */ + peek(address: bigint, size: number): Uint8Array { + return this.engine.memRead(address, size); + } + + entryPoints(): Readonly { + return this.entry; + } + + // ---- guest calls -------------------------------------------------------- + + private invoke(func: bigint, ...args: bigint[]): bigint { + if (this.closed) throw new Error("SAP guest machine is closed"); + if (func === 0n) throw new Error("SAP guest entry point is unavailable"); + + const registers = [ + this.engine.regRDI, + this.engine.regRSI, + this.engine.regRDX, + this.engine.regRCX, + this.engine.regR8, + this.engine.regR9, + ]; + + for (let index = 0; index < registers.length; index++) { + this.engine.regWrite(registers[index], args[index] ?? 0n); + } + + const extra = Math.max(args.length - registers.length, 0); + + // The System V ABI wants RSP+8 to be 16-byte aligned at the entry point, + // which after the pushed return address means RSP % 16 == 8. + let stackPointer = STACK_END - BigInt(extra + 1) * 8n; + if (stackPointer % 16n !== 8n) stackPointer -= 8n; + + this.engine.writeUint64(stackPointer, RETURN_ADDRESS); + for (let index = 0; index < extra; index++) { + this.engine.writeUint64( + stackPointer + 8n + BigInt(index) * 8n, + args[registers.length + index], + ); + } + this.engine.regWrite(this.engine.regRSP, stackPointer); + + this.shims.resetFault(); + + try { + this.run(func); + } catch (error) { + if (this.shims.fault) throw this.shims.fault; + throw new Error( + `execute SAP guest function: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (this.shims.fault) throw this.shims.fault; + + const instruction = this.engine.regRead(this.engine.regRIP); + if (instruction !== RETURN_ADDRESS) { + throw new Error(`SAP guest stopped unexpectedly at 0x${instruction.toString(16)}`); + } + + return this.engine.regRead(this.engine.regRAX); + } + + /** + * Runs the guest from `start` until it returns to the trampoline, splitting + * any basic block that would exceed what unicorn.js can translate. + * + * The split is transparent to the guest: a HLT replaces one byte, execution + * stops on it with RIP at that address, the original byte goes back, and + * execution resumes from the same place. Only the emulator notices, and only + * by translating a shorter block. + */ + private run(start: bigint): void { + let address = start; + let budget = INSTRUCTION_LIMIT; + + while (budget > 0) { + const block = this.measure(address); + + if (!block) { + // Undecodable: let the emulator run unassisted, which is no worse + // than not splitting at all. + this.segment(address, budget, null); + budget = 0; + } else if (!block.complete) { + // Too long to translate whole. Stop it at the last safe boundary and + // resume from there; the guest cannot tell, only the translator can. + this.segment(address, block.instructions, address + BigInt(block.end)); + budget -= block.instructions; + } else { + // Run the body first, so the terminator executes on its own. Taking + // a branch also translates its target, so the target's block has to + // be made safe before the branch runs, not after. + const body = block.instructions - 1; + if (body > 0) { + this.segment(address, body, null); + budget -= body; + } + + const terminator = address + BigInt(block.terminator!.offset); + const current = this.engine.regRead(this.engine.regRIP); + if (current === RETURN_ADDRESS || this.shims.fault) return; + if (body > 0 && current !== terminator) { + address = current; + continue; + } + + this.segment(terminator, 1, this.targetCut(block, address)); + budget -= 1; + } + + let next = this.engine.regRead(this.engine.regRIP); + if (next === RETURN_ADDRESS || this.shims.fault) return; + + if (next === address) { + // A segment can legitimately stop where it started, for instance when + // the split point falls on the instruction about to run and the guard + // drops it. Step once unassisted to get past it. + this.segment(address, 1, null); + budget -= 1; + + next = this.engine.regRead(this.engine.regRIP); + if (next === RETURN_ADDRESS || this.shims.fault) return; + if (next === address) { + throw new Error(`SAP guest made no progress at 0x${address.toString(16)}`); + } + } + + address = next; + } + + throw new Error("SAP guest exceeded its instruction budget"); + } + + /** Runs `count` instructions from `address`, with `cut` held as a HLT. */ + private segment(address: bigint, count: number, cut: bigint | null): void { + // A tight loop can put the split point on the very branch about to run — + // the back edge of a nine-instruction loop lands there whenever the limit + // is eight. Overwriting it would leave the tail of that branch stranded as + // its own instruction, so leave short blocks unsplit instead. + if (cut !== null && cut >= address && cut < address + 16n) { + cut = null; + } + + let original = 0; + if (cut !== null) { + original = this.engine.memRead(cut, 1)[0]; + this.engine.memWrite(cut, new Uint8Array([0xf4])); + } + + try { + this.engine.start(address, RETURN_ADDRESS, EXECUTION_TIMEOUT_MICROS, count); + } finally { + if (cut !== null) this.engine.memWrite(cut, new Uint8Array([original])); + } + } + + /** + * Where to plant a HLT so the block a terminator jumps into is safe to + * translate, or null when the target is unknown or already short. + * + * Direct branches carry their displacement; a return reads its target off + * the stack. Indirect branches would need the effective address evaluated, + * so they go unhandled and rely on their target being short. + */ + private targetCut(block: Block, base: bigint): bigint | null { + const { offset, decoded } = block.terminator!; + const address = base + BigInt(offset); + + let target: bigint; + try { + if (decoded.relative !== undefined) { + target = address + BigInt(decoded.length + decoded.relative); + } else if (decoded.indirect) { + const resolved = this.indirectTarget(decoded, address); + if (resolved === null) return null; + target = resolved; + } else if (this.engine.memRead(address, 1)[0] === 0xc3) { + target = this.engine.readUint64(this.engine.regRead(this.engine.regRSP)); + } else { + return null; + } + } catch { + return null; + } + + const measured = this.measure(target); + return measured && !measured.complete ? target + BigInt(measured.end) : null; + } + + /** Evaluates the destination of an indirect call or jump. */ + private indirectTarget(decoded: Decoded, address: bigint): bigint | null { + const form = decoded.indirect!; + if (form.register !== null) return this.engine.gpr(form.register); + + let effective = BigInt(form.displacement); + + if (form.ripRelative) { + effective += address + BigInt(decoded.length); + } else { + if (form.base !== null) effective += this.engine.gpr(form.base); + if (form.index !== null) { + effective += this.engine.gpr(form.index) * BigInt(form.scale); + } + } + + return this.engine.readUint64(effective & ((1n << 64n) - 1n)); + } + + /** + * Measures the block at `address`, or null when its code cannot be read or + * decoded — in which case the caller falls back to letting the emulator run + * unassisted, which is no worse than not splitting at all. + */ + private measure(address: bigint): Block | null { + try { + const window = this.engine.memRead(address, CODE_WINDOW); + return measureBlock(window, 0, MAX_BLOCK_INSTRUCTIONS); + } catch { + return null; + } + } + + private beginCall(): void { + this.scratchCursor = 0n; + } + + /** Bump allocator for one call's arguments and output slots. */ + private scratch(data: Uint8Array | null, size: bigint): bigint { + const reserved = align(size > 1n ? size : 1n, 16n); + if (this.scratchCursor > SCRATCH_SIZE || reserved > SCRATCH_SIZE - this.scratchCursor) { + throw new Error("SAP guest scratch space exhausted"); + } + + const address = SCRATCH_BASE + this.scratchCursor; + this.scratchCursor += reserved; + + if (data && data.length !== 0) { + if (BigInt(data.length) > size) { + throw new Error("scratch data exceeds reservation"); + } + this.engine.memWrite(address, data); + } else if (size !== 0n) { + this.engine.memZero(address, Number(size)); + } + + return address; + } + + private clearScratch(): void { + if (this.scratchCursor !== 0n && !this.closed) { + this.engine.memZero(SCRATCH_BASE, Number(this.scratchCursor)); + } + this.scratchCursor = 0n; + } + + /** Reads a guest-allocated buffer, then hands it back to the guest. */ + private consumeOutput(pointerField: bigint, lengthField: bigint): Uint8Array { + const pointer = this.engine.readUint64(pointerField); + const length = this.engine.readUint64(lengthField); + + let output = new Uint8Array(0); + let failure: Error | null = null; + + if (length > MAX_OUTPUT_SIZE) { + failure = new Error(`SAP output is ${length} bytes, maximum is ${MAX_OUTPUT_SIZE}`); + } else if (length !== 0n) { + if (pointer === 0n) { + failure = new Error("SAP returned a null output pointer"); + } else { + output = new Uint8Array(this.engine.memRead(pointer, Number(length))); + } + } + + if (pointer !== 0n) { + const status = this.invoke(this.entry.dispose, pointer); + if (asInt32(status) !== 0 && !failure) { + failure = new Error(`SAP dispose returned ${asInt32(status)}`); + } + } + + if (failure) throw failure; + return output; + } +} + +/** The guest expects a length-prefixed hardware identity in a 24-byte block. */ +function hardwareBlock(hardwareID: Uint8Array): Uint8Array { + if (hardwareID.length === 0 || hardwareID.length > 20) { + throw new Error("hardware ID must contain between 1 and 20 bytes"); + } + + const result = new Uint8Array(24); + new DataView(result.buffer).setUint32(0, hardwareID.length, true); + result.set(hardwareID, 4); + + return result; +} + +function asInt32(value: bigint): number { + return Number(BigInt.asIntN(32, value)); +} diff --git a/frontend/src/apple/sap/macho.ts b/frontend/src/apple/sap/macho.ts new file mode 100644 index 00000000..f86484d3 --- /dev/null +++ b/frontend/src/apple/sap/macho.ts @@ -0,0 +1,556 @@ +// Minimal Mach-O loader for the SAP guest images. +// +// Ported from ipatool's internal/sap/machimage. Only what those three images +// need is implemented: the x86-64 slice of a universal binary, LC_SEGMENT_64, +// LC_SYMTAB lookups, and the LC_DYLD_INFO rebase and bind opcode streams. +// +// The Go original leans on go-macho for the opcode walk and reports each +// rebase with the pointer already read out of the file. Reading that pointer +// directly here is equivalent and removes the need to mirror go-macho's +// structures, so the walk only has to yield (segment, offset) pairs. + +const FAT_MAGIC = 0xcafebabe; +const MH_MAGIC_64 = 0xfeedfacf; +const CPU_TYPE_X86_64 = 0x01000007; + +const LC_SEGMENT_64 = 0x19; +const LC_SYMTAB = 0x02; +const LC_DYLD_INFO = 0x22; +const LC_DYLD_INFO_ONLY = 0x80000022; + +const REBASE_TYPE_POINTER = 1; +const BIND_TYPE_POINTER = 1; + +const REBASE_OPCODE_MASK = 0xf0; +const REBASE_IMMEDIATE_MASK = 0x0f; +const REBASE_OPCODE_DONE = 0x00; +const REBASE_OPCODE_SET_TYPE_IMM = 0x10; +const REBASE_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB = 0x20; +const REBASE_OPCODE_ADD_ADDR_ULEB = 0x30; +const REBASE_OPCODE_ADD_ADDR_IMM_SCALED = 0x40; +const REBASE_OPCODE_DO_REBASE_IMM_TIMES = 0x50; +const REBASE_OPCODE_DO_REBASE_ULEB_TIMES = 0x60; +const REBASE_OPCODE_DO_REBASE_ADD_ADDR_ULEB = 0x70; +const REBASE_OPCODE_DO_REBASE_ULEB_TIMES_SKIPPING_ULEB = 0x80; + +const BIND_OPCODE_MASK = 0xf0; +const BIND_IMMEDIATE_MASK = 0x0f; +const BIND_OPCODE_DONE = 0x00; +const BIND_OPCODE_SET_DYLIB_ORDINAL_IMM = 0x10; +const BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB = 0x20; +const BIND_OPCODE_SET_DYLIB_SPECIAL_IMM = 0x30; +const BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM = 0x40; +const BIND_OPCODE_SET_TYPE_IMM = 0x50; +const BIND_OPCODE_SET_ADDEND_SLEB = 0x60; +const BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB = 0x70; +const BIND_OPCODE_ADD_ADDR_ULEB = 0x80; +const BIND_OPCODE_DO_BIND = 0x90; +const BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB = 0xa0; +const BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED = 0xb0; +const BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB = 0xc0; + +// Segment offsets are uint64 in the original, and linkers lean on that: a +// backward jump is emitted as a ULEB that wraps, e.g. 0xfffffffffffff6c8 for +// -0x938. BigInt has no width, so every offset step masks back to 64 bits. +const U64_MASK = (1n << 64n) - 1n; + +const POINTER_SIZE = 8n; +const PAGE_SIZE = 0x1000n; +const MAX_IMAGE_SPAN = 1n << 30n; + +export interface GuestMemory { + memMap(address: bigint, size: bigint): void; + memWrite(address: bigint, data: Uint8Array): void; +} + +interface Segment { + name: string; + address: bigint; + size: bigint; + fileOff: bigint; + fileSize: bigint; +} + +interface Fixup { + segment: number; + offset: bigint; +} + +interface Bind extends Fixup { + name: string; + addend: bigint; + type: number; +} + +/** Reads the little-endian primitives the Mach-O structures are made of. */ +class Cursor { + private readonly view: DataView; + private offset: number; + + constructor(view: DataView, start = 0) { + this.view = view; + this.offset = start; + } + + get position(): number { + return this.offset; + } + + atEnd(limit: number): boolean { + return this.offset >= limit; + } + + u8(): number { + return this.view.getUint8(this.offset++); + } + + u32(): number { + const value = this.view.getUint32(this.offset, true); + this.offset += 4; + return value; + } + + u64(): bigint { + const value = this.view.getBigUint64(this.offset, true); + this.offset += 8; + return value; + } + + /** LEB128, as used throughout the dyld opcode streams. */ + uleb(): bigint { + let result = 0n; + let shift = 0n; + for (;;) { + const byte = this.u8(); + result |= BigInt(byte & 0x7f) << shift; + if ((byte & 0x80) === 0) return result; + shift += 7n; + if (shift > 70n) throw new Error("ULEB128 value is too large"); + } + } + + sleb(): bigint { + let result = 0n; + let shift = 0n; + for (;;) { + const byte = this.u8(); + result |= BigInt(byte & 0x7f) << shift; + shift += 7n; + if ((byte & 0x80) === 0) { + if (byte & 0x40) result -= 1n << shift; + return result; + } + if (shift > 70n) throw new Error("SLEB128 value is too large"); + } + } + + cstring(): string { + let text = ""; + for (;;) { + const byte = this.u8(); + if (byte === 0) return text; + text += String.fromCharCode(byte); + } + } +} + +function align(value: bigint, boundary: bigint): bigint { + const remainder = value % boundary; + return remainder === 0n ? value : value + (boundary - remainder); +} + +/** Picks the x86-64 member out of a universal binary, or passes a thin one through. */ +function amd64Slice(input: Uint8Array): Uint8Array { + if (input.length < 8) throw new Error("image is too small to be Mach-O"); + + const view = new DataView(input.buffer, input.byteOffset, input.byteLength); + if (view.getUint32(0, false) !== FAT_MAGIC) return input; + + const count = view.getUint32(4, false); + for (let index = 0; index < count; index++) { + const entry = 8 + index * 20; + const cpu = view.getUint32(entry, false); + if (cpu !== CPU_TYPE_X86_64) continue; + + const offset = view.getUint32(entry + 8, false); + const size = view.getUint32(entry + 12, false); + if (offset + size > input.length) { + throw new Error("x86-64 slice exceeds input size"); + } + return input.subarray(offset, offset + size); + } + + throw new Error("universal binary has no x86-64 slice"); +} + +export class MachImage { + private readonly data: Uint8Array; + private readonly view: DataView; + private readonly segments: Segment[] = []; + private readonly symbols = new Map(); + private readonly rebases: Fixup[] = []; + private readonly binds: Bind[] = []; + + readonly name: string; + readonly base: bigint; + + private relocated = false; + private loadedBase = 0n; + private loadedSpan = 0n; + + constructor(name: string, input: Uint8Array) { + this.name = name; + // subarray keeps the parent buffer alive, so copy the slice we keep. + this.data = new Uint8Array(amd64Slice(input)); + this.view = new DataView(this.data.buffer); + + if (this.view.getUint32(0, true) !== MH_MAGIC_64) { + throw new Error(`${name} is not a 64-bit Mach-O`); + } + if (this.view.getUint32(4, true) !== CPU_TYPE_X86_64) { + throw new Error(`${name} is not x86-64`); + } + + this.parseLoadCommands(); + this.base = this.textBase(); + this.validateSegments(); + } + + private parseLoadCommands(): void { + const count = this.view.getUint32(16, true); + let offset = 32; + + for (let index = 0; index < count; index++) { + const command = this.view.getUint32(offset, true); + const size = this.view.getUint32(offset + 4, true); + if (size === 0) throw new Error(`${this.name} has a zero-length load command`); + + if (command === LC_SEGMENT_64) { + this.parseSegment(offset); + } else if (command === LC_SYMTAB) { + this.parseSymtab(offset); + } else if (command === LC_DYLD_INFO || command === LC_DYLD_INFO_ONLY) { + this.parseDyldInfo(offset); + } + + offset += size; + } + } + + private parseSegment(offset: number): void { + let name = ""; + for (let index = 0; index < 16; index++) { + const byte = this.view.getUint8(offset + 8 + index); + if (byte === 0) break; + name += String.fromCharCode(byte); + } + + this.segments.push({ + name, + address: this.view.getBigUint64(offset + 24, true), + size: this.view.getBigUint64(offset + 32, true), + fileOff: this.view.getBigUint64(offset + 40, true), + fileSize: this.view.getBigUint64(offset + 48, true), + }); + } + + private parseSymtab(offset: number): void { + const symbolOffset = this.view.getUint32(offset + 8, true); + const symbolCount = this.view.getUint32(offset + 12, true); + const stringOffset = this.view.getUint32(offset + 16, true); + const stringSize = this.view.getUint32(offset + 20, true); + + for (let index = 0; index < symbolCount; index++) { + const entry = symbolOffset + index * 16; + if (entry + 16 > this.data.length) break; + + const nameOffset = this.view.getUint32(entry, true); + const type = this.view.getUint8(entry + 4); + const value = this.view.getBigUint64(entry + 8, true); + + // N_STAB entries are debug symbols; N_TYPE must be N_SECT to have an + // address worth resolving. + if (type & 0xe0) continue; + if ((type & 0x0e) !== 0x0e) continue; + if (nameOffset === 0 || nameOffset >= stringSize) continue; + + let symbol = ""; + let cursor = stringOffset + nameOffset; + while (cursor < this.data.length) { + const byte = this.data[cursor++]; + if (byte === 0) break; + symbol += String.fromCharCode(byte); + } + + if (symbol && !this.symbols.has(symbol)) this.symbols.set(symbol, value); + } + } + + private parseDyldInfo(offset: number): void { + const rebaseOff = this.view.getUint32(offset + 8, true); + const rebaseSize = this.view.getUint32(offset + 12, true); + const bindOff = this.view.getUint32(offset + 16, true); + const bindSize = this.view.getUint32(offset + 20, true); + const weakBindOff = this.view.getUint32(offset + 24, true); + const weakBindSize = this.view.getUint32(offset + 28, true); + const lazyBindOff = this.view.getUint32(offset + 32, true); + const lazyBindSize = this.view.getUint32(offset + 36, true); + + if (rebaseSize > 0) this.walkRebase(rebaseOff, rebaseOff + rebaseSize); + if (bindSize > 0) this.walkBind(bindOff, bindOff + bindSize, false); + if (weakBindSize > 0) this.walkBind(weakBindOff, weakBindOff + weakBindSize, false); + if (lazyBindSize > 0) this.walkBind(lazyBindOff, lazyBindOff + lazyBindSize, true); + } + + private walkRebase(start: number, end: number): void { + const cursor = new Cursor(this.view, start); + let type = 0; + let segment = 0; + let offset = 0n; + + const emit = (count: bigint, skip: bigint) => { + for (let index = 0n; index < count; index++) { + if (type !== REBASE_TYPE_POINTER) { + throw new Error(`${this.name} uses unsupported rebase type ${type}`); + } + this.rebases.push({ segment, offset }); + offset = (offset + POINTER_SIZE + skip) & U64_MASK; + } + }; + + while (!cursor.atEnd(end)) { + const byte = cursor.u8(); + const opcode = byte & REBASE_OPCODE_MASK; + const immediate = byte & REBASE_IMMEDIATE_MASK; + + switch (opcode) { + case REBASE_OPCODE_DONE: + return; + case REBASE_OPCODE_SET_TYPE_IMM: + type = immediate; + break; + case REBASE_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB: + segment = immediate; + offset = cursor.uleb() & U64_MASK; + break; + case REBASE_OPCODE_ADD_ADDR_ULEB: + offset = (offset + cursor.uleb()) & U64_MASK; + break; + case REBASE_OPCODE_ADD_ADDR_IMM_SCALED: + offset = (offset + BigInt(immediate) * POINTER_SIZE) & U64_MASK; + break; + case REBASE_OPCODE_DO_REBASE_IMM_TIMES: + emit(BigInt(immediate), 0n); + break; + case REBASE_OPCODE_DO_REBASE_ULEB_TIMES: + emit(cursor.uleb(), 0n); + break; + case REBASE_OPCODE_DO_REBASE_ADD_ADDR_ULEB: { + const skip = cursor.uleb(); + emit(1n, skip); + break; + } + case REBASE_OPCODE_DO_REBASE_ULEB_TIMES_SKIPPING_ULEB: { + const count = cursor.uleb(); + const skip = cursor.uleb(); + emit(count, skip); + break; + } + default: + throw new Error(`${this.name} uses unknown rebase opcode ${opcode}`); + } + } + } + + // A lazy stream uses DONE to separate one symbol's sequence from the next + // and runs to the end of its range; the regular and weak streams end at the + // first DONE, with padding after it that must not be parsed. + private walkBind(start: number, end: number, lazy: boolean): void { + const cursor = new Cursor(this.view, start); + // Lazy bind streams may omit SET_TYPE; dyld's default is a pointer bind. + let type = BIND_TYPE_POINTER; + let segment = 0; + let offset = 0n; + let addend = 0n; + let symbol = ""; + + const emit = (skip: bigint) => { + if (type !== BIND_TYPE_POINTER) { + throw new Error(`${this.name} uses unsupported bind type ${type} for ${symbol}`); + } + this.binds.push({ segment, offset, name: symbol, addend, type }); + offset = (offset + POINTER_SIZE + skip) & U64_MASK; + }; + + while (!cursor.atEnd(end)) { + const byte = cursor.u8(); + const opcode = byte & BIND_OPCODE_MASK; + const immediate = byte & BIND_IMMEDIATE_MASK; + + switch (opcode) { + case BIND_OPCODE_DONE: + if (!lazy) return; + break; + case BIND_OPCODE_SET_DYLIB_ORDINAL_IMM: + case BIND_OPCODE_SET_DYLIB_SPECIAL_IMM: + break; + case BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB: + cursor.uleb(); + break; + case BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM: + symbol = cursor.cstring(); + break; + case BIND_OPCODE_SET_TYPE_IMM: + type = immediate; + break; + case BIND_OPCODE_SET_ADDEND_SLEB: + addend = cursor.sleb(); + break; + case BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB: + segment = immediate; + offset = cursor.uleb() & U64_MASK; + break; + case BIND_OPCODE_ADD_ADDR_ULEB: + offset = (offset + cursor.uleb()) & U64_MASK; + break; + case BIND_OPCODE_DO_BIND: + emit(0n); + break; + case BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB: + emit(cursor.uleb()); + break; + case BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED: + emit(BigInt(immediate) * POINTER_SIZE); + break; + case BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB: { + const count = cursor.uleb(); + const skip = cursor.uleb(); + for (let index = 0n; index < count; index++) emit(skip); + break; + } + default: + throw new Error(`${this.name} uses unknown bind opcode ${opcode}`); + } + } + } + + private textBase(): bigint { + for (const item of this.segments) { + if (item.name === "__TEXT") return item.address; + } + throw new Error(`${this.name} has no __TEXT segment`); + } + + private validateSegments(): void { + for (const item of this.segments) { + if (item.fileSize > item.size) { + throw new Error(`segment ${item.name} file data exceeds its memory size in ${this.name}`); + } + if (item.fileOff + item.fileSize > BigInt(this.data.length)) { + throw new Error(`segment ${item.name} data exceeds ${this.name}`); + } + } + } + + /** File offset of a fixup, with the same bounds checks as the Go original. */ + private segmentFileOffset(index: number, offset: bigint, size: bigint): bigint { + const item = this.segments[index]; + if (!item) { + throw new Error(`fixup references unknown segment ${index} in ${this.name}`); + } + + const end = offset + size; + if (end > item.size) { + throw new Error(`fixup at ${offset} exceeds segment ${item.name} in ${this.name}`); + } + if (end > item.fileSize) { + throw new Error(`fixup at ${offset} exceeds file data for segment ${item.name} in ${this.name}`); + } + + return item.fileOff + offset; + } + + private putPointer(offset: bigint, value: bigint): void { + this.view.setBigUint64(Number(offset), value, true); + } + + /** Absolute guest address of an exported symbol once loaded at loadBase. */ + export(name: string, loadBase: bigint): bigint { + const address = this.symbols.get(name); + if (address === undefined) { + throw new Error(`find ${name} in ${this.name}`); + } + if (address < this.base) { + throw new Error(`symbol ${name} in ${this.name} precedes image base`); + } + return loadBase + (address - this.base); + } + + /** Applies rebases and binds in place, exactly as machimage.Relocate does. */ + relocate(loadBase: bigint, resolve: (symbol: string) => bigint): void { + if (this.relocated) { + throw new Error(`${this.name} is already relocated`); + } + + for (const item of this.rebases) { + const offset = this.segmentFileOffset(item.segment, item.offset, POINTER_SIZE); + const original = this.view.getBigUint64(Number(offset), true); + if (original < this.base) { + throw new Error(`${this.name} contains a rebase below its image base`); + } + this.putPointer(offset, loadBase + (original - this.base)); + } + + for (const item of this.binds) { + const offset = this.segmentFileOffset(item.segment, item.offset, POINTER_SIZE); + this.putPointer(offset, resolve(item.name) + item.addend); + } + + this.relocated = true; + this.loadedBase = loadBase; + } + + /** Maps the image span and writes every segment's file data into the guest. */ + load(memory: GuestMemory): void { + if (!this.relocated) { + throw new Error(`${this.name} must be relocated before loading`); + } + + let span = 0n; + for (const item of this.segments) { + if (item.name === "__PAGEZERO" || item.size === 0n) continue; + if (item.address < this.base) { + throw new Error(`segment ${item.name} in ${this.name} precedes image base`); + } + + const end = item.address - this.base + item.size; + if (end > MAX_IMAGE_SPAN) { + throw new Error(`segment ${item.name} makes ${this.name} too large`); + } + if (end > span) span = end; + } + + span = align(span, PAGE_SIZE); + if (span === 0n) { + throw new Error(`${this.name} has no loadable segments`); + } + + memory.memMap(this.loadedBase, span); + this.loadedSpan = span; + + for (const item of this.segments) { + if (item.name === "__PAGEZERO" || item.fileSize === 0n) continue; + + const start = Number(item.fileOff); + const end = Number(item.fileOff + item.fileSize); + memory.memWrite( + this.loadedBase + (item.address - this.base), + this.data.subarray(start, end), + ); + } + } + + loadedRange(): { base: bigint; span: bigint } { + return { base: this.loadedBase, span: this.loadedSpan }; + } +} diff --git a/frontend/src/apple/sap/platform.ts b/frontend/src/apple/sap/platform.ts new file mode 100644 index 00000000..f2359f96 --- /dev/null +++ b/frontend/src/apple/sap/platform.ts @@ -0,0 +1,271 @@ +// Platform services for the SAP guest. +// +// Ported from ipatool's internal/sap/machine/shim_platform.go. The guest is a +// 2013 CommerceKit that expects macOS underneath it: CoreFoundation, IOKit, +// dlopen, a filesystem. None of that exists here, so each import answers with +// the least the signing path needs — usually a constant, sometimes a fake +// handle the guest only ever passes back to another shim. +// +// The one import that carries real data is _read, which streams CoreFP.icxs +// after the guest opens it by its relative path. + +import type { Engine } from "./engine"; +import type { Shims } from "./shims"; + +const FAKE_HANDLE = (1n << 64n) - 1n; +const UINT32_MAX = 0xffffffffn; +const MINUS_ONE = (1n << 64n) - 1n; + +const CORE_FP_FILE = 3n; +const CORE_FP_PATH = "/System/Library/PrivateFrameworks/CoreFP.framework/CoreFP"; +const ICXS_PATH = "./../CoreFP.icxs"; + +const KEY_SERIAL = "IOPlatformSerialNumber"; +const KEY_UUID = "IOPlatformUUID"; +const KEY_BOARD = "board-id"; +const KEYED_MESSAGE = "objectForKey:"; + +export function registerPlatformServices( + shims: Shims, + engine: Engine, + coreExports: Map, + icxs: Uint8Array, +): void { + // Guest state that outlives a single call. + let iterator = 0; + let icxsOffset = 0; + + const returnZero = () => shims.setResult(0n); + const returnFakeHandle = () => shims.setResult(FAKE_HANDLE); + const returnMinusOne = () => shims.setResult(MINUS_ONE); + + shims.addAliases( + [ + "_CFBundleGetMainBundle", + "_CFDataGetBytePtr", + "_CFDataGetLength", + "_CFStringGetLength", + "_CFStringGetMaximumSizeForEncoding", + "_CFUUIDCreateString", + "_IORegistryEntryFromPath", + "_IORegistryEntrySearchCFProperty", + "_IOServiceMatching", + "_getenv", + "_pthread_self", + ], + returnZero, + ); + + shims.addAliases( + [ + "_CFDictionaryGetValue", + "_DADiskCopyDescription", + "_DADiskCreateFromBSDName", + "_DASessionCreate", + "_IORegistryEntryCreateCFProperty", + ], + returnFakeHandle, + ); + + shims.addAliases( + [ + "_CFRelease", + "_IOObjectRelease", + "_close", + "_close$UNIX2003", + "_pthread_mutex_lock", + "_pthread_mutex_unlock", + "_pthread_rwlock_init", + "_pthread_rwlock_init$UNIX2003", + "_pthread_rwlock_unlock", + "_pthread_rwlock_unlock$UNIX2003", + "_pthread_rwlock_wrlock", + "_pthread_rwlock_wrlock$UNIX2003", + ], + returnZero, + ); + + // Only the three hardware keys need to look real; everything else the guest + // asks CoreFoundation for can come back null. + shims.addAliases(["_CFStringCreateWithCString"], () => { + const value = shims.readCString(shims.argument(1)); + const known = value === KEY_SERIAL || value === KEY_UUID || value === KEY_BOARD; + shims.setResult(known ? FAKE_HANDLE : 0n); + }); + + shims.addAliases(["_CFStringCreateWithCStringNoCopy"], returnZero); + + shims.addAliases(["_CFStringGetCString"], () => { + const buffer = shims.argument(1); + const capacity = shims.argument(2); + if (buffer === 0n || capacity === 0n) { + shims.setResult(0n); + return; + } + engine.memWrite(buffer, new Uint8Array([0])); + shims.setResult(1n); + }); + + // The guest walks an IOKit iterator; yielding one entry then zero ends it. + shims.addAliases(["_IOIteratorNext"], () => { + iterator++; + shims.setResult(BigInt(iterator % 2)); + }); + + shims.addAliases(["_IORegistryEntryGetParentEntry"], () => { + const parent = shims.argument(2); + if (parent === 0n) throw new Error("parent registry entry output is null"); + engine.writeUint32(parent, Number(UINT32_MAX)); + shims.setResult(0n); + }); + + shims.addAliases(["_IOServiceGetMatchingServices"], () => { + const output = shims.argument(2); + if (output === 0n) throw new Error("matching services iterator output is null"); + iterator = 0; + engine.writeUint32(output, Number(UINT32_MAX)); + shims.setResult(0n); + }); + + shims.addAliases(["_IOServiceGetMatchingService"], () => shims.setResult(UINT32_MAX)); + + shims.addAliases(["_OSAtomicCompareAndSwap32Barrier"], () => { + const oldValue = Number(shims.argument(0) & UINT32_MAX); + const newValue = Number(shims.argument(1) & UINT32_MAX); + const address = shims.argument(2); + + if (engine.readUint32(address) !== oldValue) { + shims.setResult(0n); + return; + } + engine.writeUint32(address, newValue); + shims.setResult(1n); + }); + + shims.addAliases(["_abort", "___stack_chk_fail", "dyld_stub_binder"], () => { + throw new Error("guest aborted"); + }); + + shims.addAliases(["_arc4random"], () => { + const value = new Uint32Array(1); + crypto.getRandomValues(value); + shims.setResult(BigInt(value[0])); + }); + + shims.addAliases(["_dlopen"], () => { + const path = shims.readCString(shims.argument(0)); + shims.setResult(path === CORE_FP_PATH ? FAKE_HANDLE : 0n); + }); + + // The guest resolves CoreFP's own exports through dlsym; hand back the + // addresses the loader already resolved. + shims.addAliases(["_dlsym"], () => { + const name = shims.readCString(shims.argument(1)); + shims.setResult(coreExports.get(`_${name}`) ?? 0n); + }); + + shims.addAliases( + ["_fcntl", "_fcntl$UNIX2003", "_lstat$INODE64", "_statfs", "_statfs$INODE64"], + returnMinusOne, + ); + + shims.addAliases(["_gettimeofday"], () => { + const timeAddress = shims.argument(0); + const zoneAddress = shims.argument(1); + const now = Date.now(); + + if (timeAddress !== 0n) { + const value = new Uint8Array(16); + const view = new DataView(value.buffer); + view.setBigUint64(0, BigInt(Math.floor(now / 1000)), true); + view.setUint32(8, (now % 1000) * 1000, true); + engine.memWrite(timeAddress, value); + } + + if (zoneAddress !== 0n) engine.memZero(zoneAddress, 8); + shims.setResult(0n); + }); + + shims.addAliases(["_objc_msgSend"], () => { + const selector = shims.readCString(shims.argument(1)); + shims.setResult(selector === KEYED_MESSAGE ? FAKE_HANDLE : 0n); + }); + + shims.addAliases(["_open", "_open$UNIX2003"], () => { + const path = shims.readCString(shims.argument(0)); + if (path !== ICXS_PATH) { + returnMinusOne(); + return; + } + icxsOffset = 0; + shims.setResult(CORE_FP_FILE); + }); + + // pthread_once has to actually run the initializer, so push it as a return + // address and let the guest fall into it when this shim's RET executes. + shims.addAliases(["_pthread_once"], () => { + const control = shims.argument(0); + const initializer = shims.argument(1); + + if (engine.readUint64(control) === 0n) { + shims.setResult(0n); + return; + } + + engine.writeUint64(control, 0n); + + const stack = engine.regRead(engine.regRSP) - 8n; + engine.writeUint64(stack, initializer); + engine.regWrite(engine.regRSP, stack); + shims.setResult(0n); + }); + + shims.addAliases(["_read", "_read$UNIX2003"], () => { + const descriptor = shims.argument(0); + const buffer = shims.argument(1); + const requested = shims.argument(2); + + if (descriptor !== CORE_FP_FILE) { + returnMinusOne(); + return; + } + + let size = Number(requested); + const remaining = icxs.length - icxsOffset; + if (size > remaining) size = remaining; + + if (size !== 0) { + engine.memWrite(buffer, icxs.subarray(icxsOffset, icxsOffset + size)); + icxsOffset += size; + } + + shims.setResult(BigInt(size)); + }); + + shims.addAliases(["_sysctl"], returnMinusOne); + + shims.addAliases(["_sysctlbyname"], () => { + const lengthAddress = shims.argument(2); + if (lengthAddress !== 0n) engine.writeUint64(lengthAddress, 0n); + shims.setResult(0n); + }); + + // Data symbols. errno is read through ___error; the stack guard value is + // arbitrary but must stay put, since the guest compares it on return. + const errno = shims.addData("guest.errno", new Uint8Array(8)); + shims.addAliases(["___error"], () => shims.setResult(errno)); + + shims.addData( + "___stack_chk_guard", + new Uint8Array([0xa5, 0x71, 0x3c, 0xd9, 0x86, 0x42, 0xef, 0x10]), + ); + + for (const name of [ + "_kCFAllocatorDefault", + "_kCFAllocatorNull", + "_kDADiskDescriptionVolumeUUIDKey", + "_kIOMasterPortDefault", + ]) { + shims.addData(name, new Uint8Array(8)); + } +} diff --git a/frontend/src/apple/sap/shims.ts b/frontend/src/apple/sap/shims.ts new file mode 100644 index 00000000..6b0fdad1 --- /dev/null +++ b/frontend/src/apple/sap/shims.ts @@ -0,0 +1,424 @@ +// Guest service area for the SAP runtime. +// +// Ported from ipatool's internal/sap/machine/shims.go and shim_memory.go. The +// guest images import around 500 symbols; only the ones the signing path +// actually touches are implemented, and the rest get a stub that faults only +// if the guest calls it. +// +// Each service is a 16-byte slot holding a single RET. A UC_HOOK_CODE over the +// code area catches the entry, the handler runs on the host, and the RET +// returns to the caller — so the guest never notices the call left the VM. + +import type { Engine } from "./engine"; + +export const SHIM_BASE = 0x0000200000000000n; +export const SHIM_CODE_SIZE = 0x0000000000080000n; +export const SHIM_SIZE = 0x0000000000100000n; +const SHIM_SLOT_SIZE = 16n; + +export const HEAP_BASE = 0x0000400000000000n; +export const HEAP_SIZE = 64n << 20n; + +const MAX_GUEST_TRANSFER = 64n << 20n; +const U64_MASK = (1n << 64n) - 1n; + +type ShimHandler = () => void; + +interface Allocation { + size: bigint; + reserved: bigint; +} + +interface FreeBlock { + address: bigint; + size: bigint; +} + +export function align(value: bigint, alignment: bigint): bigint { + return (value + alignment - 1n) & ~(alignment - 1n); +} + +function maxBig(left: bigint, right: bigint): bigint { + return left > right ? left : right; +} + +export class Shims { + private readonly engine: Engine; + private readonly entries = new Map(); + readonly symbols = new Map(); + + private codeCursor = SHIM_BASE; + private dataCursor = SHIM_BASE + SHIM_CODE_SIZE; + + private heapCursor = 0n; + private readonly allocations = new Map(); + private freeBlocks: FreeBlock[] = []; + + fault: Error | null = null; + + /** + * Called with each service the guest enters. The guest is an opaque binary, + * so the call sequence is the only window into what it is doing when a run + * goes wrong. + */ + trace: ((name: string) => void) | null = null; + + constructor(engine: Engine) { + this.engine = engine; + this.engine.memMap(SHIM_BASE, SHIM_SIZE); + this.registerMemoryServices(); + } + + /** Called by the machine once every service group is registered. */ + installHook(): void { + this.engine.addCodeHook(SHIM_BASE, SHIM_BASE + SHIM_CODE_SIZE - 1n, (address) => { + this.dispatch(address); + }); + } + + private dispatch(address: bigint): void { + const entry = this.entries.get(address); + if (!entry) { + this.fail(new Error(`guest entered unknown service address 0x${address.toString(16)}`)); + return; + } + + this.trace?.(entry.name); + + try { + entry.handler(); + } catch (error) { + this.fail( + new Error(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`), + ); + } + } + + fail(error: Error): void { + if (!this.fault) this.fault = error; + this.engine.stop(); + } + + resetFault(): void { + this.fault = null; + } + + /** Reserves a slot holding a lone RET and remembers its handler. */ + addFunction(name: string, handler: ShimHandler): bigint { + const existing = this.symbols.get(name); + if (existing !== undefined) return existing; + + if (this.codeCursor + SHIM_SLOT_SIZE > SHIM_BASE + SHIM_CODE_SIZE) { + throw new Error("guest service code area is full"); + } + + const address = this.codeCursor; + this.codeCursor += SHIM_SLOT_SIZE; + + this.engine.memWrite(address, new Uint8Array([0xc3])); + this.entries.set(address, { name, handler }); + this.symbols.set(name, address); + + return address; + } + + addAliases(names: string[], handler: ShimHandler): void { + for (const name of names) this.addFunction(name, handler); + } + + addData(name: string, data: Uint8Array): bigint { + const existing = this.symbols.get(name); + if (existing !== undefined) return existing; + + this.dataCursor = align(this.dataCursor, 8n); + if (this.dataCursor + BigInt(data.length) > SHIM_BASE + SHIM_SIZE) { + throw new Error("guest service data area is full"); + } + + const address = this.dataCursor; + this.dataCursor += maxBig(BigInt(data.length), 8n); + + this.engine.memWrite(address, data); + this.symbols.set(name, address); + + return address; + } + + /** Unknown imports resolve to a stub that only faults when entered. */ + resolve(name: string): bigint { + const existing = this.symbols.get(name); + if (existing !== undefined) return existing; + + return this.addFunction(name, () => { + throw new Error(`guest called unsupported import ${name}`); + }); + } + + // ---- calling convention ------------------------------------------------- + + argument(index: number): bigint { + const registers = [ + this.engine.regRDI, + this.engine.regRSI, + this.engine.regRDX, + this.engine.regRCX, + this.engine.regR8, + this.engine.regR9, + ]; + + if (index < 0) throw new Error("negative guest argument index"); + if (index < registers.length) return this.engine.regRead(registers[index]); + + const stack = this.engine.regRead(this.engine.regRSP); + return this.engine.readUint64(stack + 8n + BigInt(index - registers.length) * 8n); + } + + setResult(value: bigint): void { + this.engine.regWrite(this.engine.regRAX, value & U64_MASK); + } + + readCString(address: bigint): string { + const maximum = 4096; + let text = ""; + for (let offset = 0; offset < maximum; offset++) { + const byte = this.engine.memRead(address + BigInt(offset), 1)[0]; + if (byte === 0) return text; + text += String.fromCharCode(byte); + } + throw new Error(`guest string exceeds ${maximum} bytes`); + } + + private checkedSize(value: bigint): number { + if (value > MAX_GUEST_TRANSFER) { + throw new Error(`guest transfer size ${value} exceeds limit`); + } + return Number(value); + } + + // ---- allocator ---------------------------------------------------------- + + allocate(size: bigint): bigint { + if (size > MAX_GUEST_TRANSFER) { + throw new Error(`allocation size ${size} exceeds limit`); + } + + const reserved = align(maxBig(size, 1n), 16n); + + for (let index = 0; index < this.freeBlocks.length; index++) { + const block = this.freeBlocks[index]; + if (block.size < reserved) continue; + + const address = block.address; + if (block.size === reserved) { + this.freeBlocks.splice(index, 1); + } else { + block.address += reserved; + block.size -= reserved; + } + + this.allocations.set(address, { size, reserved }); + return address; + } + + if (this.heapCursor > HEAP_SIZE || reserved > HEAP_SIZE - this.heapCursor) { + throw new Error("guest heap exhausted"); + } + + const address = HEAP_BASE + this.heapCursor; + this.heapCursor += reserved; + this.allocations.set(address, { size, reserved }); + + return address; + } + + release(address: bigint): void { + const allocation = this.allocations.get(address); + if (!allocation) { + throw new Error(`free unknown pointer 0x${address.toString(16)}`); + } + + this.engine.memZero(address, Number(allocation.reserved)); + this.allocations.delete(address); + this.freeBlocks.push({ address, size: allocation.reserved }); + this.coalesceFreeBlocks(); + } + + /** Merges adjacent free blocks and gives the tail back to the heap cursor. */ + private coalesceFreeBlocks(): void { + this.freeBlocks.sort((left, right) => (left.address < right.address ? -1 : 1)); + + const merged: FreeBlock[] = []; + for (const block of this.freeBlocks) { + const last = merged[merged.length - 1]; + if (last && last.address + last.size === block.address) { + last.size += block.size; + continue; + } + merged.push({ ...block }); + } + + this.freeBlocks = merged; + while (this.freeBlocks.length !== 0) { + const block = this.freeBlocks[this.freeBlocks.length - 1]; + if (block.address + block.size !== HEAP_BASE + this.heapCursor) break; + this.heapCursor -= block.size; + this.freeBlocks.pop(); + } + } + + // ---- memory services ---------------------------------------------------- + + private registerMemoryServices(): void { + this.addAliases(["_malloc"], () => this.setResult(this.allocate(this.argument(0)))); + + this.addAliases(["_malloc_good_size"], () => + this.setResult(align(maxBig(this.argument(0), 1n), 16n)), + ); + + this.addAliases(["_malloc_size"], () => { + const allocation = this.allocations.get(this.argument(0)); + this.setResult(allocation ? allocation.reserved : 0n); + }); + + this.addAliases(["_calloc"], () => { + const count = this.argument(0); + const size = this.argument(1); + if (count !== 0n && size > U64_MASK / count) { + throw new Error("allocation size overflows"); + } + + const total = count * size; + const address = this.allocate(total); + if (total !== 0n) this.engine.memZero(address, Number(total)); + this.setResult(address); + }); + + this.addAliases(["_realloc", "_reallocf"], () => this.realloc()); + + this.addAliases(["_free"], () => { + const address = this.argument(0); + if (address !== 0n) this.release(address); + this.setResult(0n); + }); + + this.addAliases(["_memcpy", "_memmove"], () => { + const destination = this.argument(0); + const source = this.argument(1); + const length = this.checkedSize(this.argument(2)); + if (length !== 0) { + this.engine.memWrite(destination, this.engine.memRead(source, length)); + } + this.setResult(destination); + }); + + this.addAliases(["_memset"], () => { + const destination = this.argument(0); + const value = Number(this.argument(1) & 0xffn); + const length = this.checkedSize(this.argument(2)); + if (length !== 0) { + this.engine.memWrite(destination, new Uint8Array(length).fill(value)); + } + this.setResult(destination); + }); + + this.addAliases(["___bzero"], () => { + const destination = this.argument(0); + this.engine.memZero(destination, this.checkedSize(this.argument(1))); + this.setResult(destination); + }); + + // The _chk variants carry the destination's known size as a fourth + // argument; the guest relies on them trapping an overflow rather than + // truncating, so refuse instead of clamping. + this.addAliases(["___memcpy_chk"], () => { + const destination = this.argument(0); + const source = this.argument(1); + const length = this.argument(2); + if (length > this.argument(3)) { + throw new Error("__memcpy_chk destination is too small"); + } + const size = this.checkedSize(length); + if (size !== 0) { + this.engine.memWrite(destination, this.engine.memRead(source, size)); + } + this.setResult(destination); + }); + + this.addAliases(["___memset_chk"], () => { + const destination = this.argument(0); + const value = Number(this.argument(1) & 0xffn); + const length = this.argument(2); + if (length > this.argument(3)) { + throw new Error("__memset_chk destination is too small"); + } + const size = this.checkedSize(length); + if (size !== 0) { + this.engine.memWrite(destination, new Uint8Array(size).fill(value)); + } + this.setResult(destination); + }); + + this.addAliases(["_memcmp"], () => { + const left = this.argument(0); + const right = this.argument(1); + const length = this.checkedSize(this.argument(2)); + this.setResult(BigInt(compareBytes( + this.engine.memRead(left, length), + this.engine.memRead(right, length), + )) & U64_MASK); + }); + + this.addAliases(["_strcmp"], () => { + const left = this.readCString(this.argument(0)); + const right = this.readCString(this.argument(1)); + this.setResult(BigInt(left < right ? -1 : left > right ? 1 : 0) & U64_MASK); + }); + + this.addAliases(["_strncmp"], () => { + const limit = Number(this.argument(2)); + const left = this.readCString(this.argument(0)).slice(0, limit); + const right = this.readCString(this.argument(1)).slice(0, limit); + this.setResult(BigInt(left < right ? -1 : left > right ? 1 : 0) & U64_MASK); + }); + + this.addAliases(["_strlen"], () => + this.setResult(BigInt(this.readCString(this.argument(0)).length)), + ); + } + + private realloc(): void { + const oldAddress = this.argument(0); + const newSize = this.argument(1); + + if (oldAddress === 0n) { + this.setResult(this.allocate(newSize)); + return; + } + + const allocation = this.allocations.get(oldAddress); + if (!allocation) { + throw new Error(`reallocate unknown pointer 0x${oldAddress.toString(16)}`); + } + + if (newSize <= allocation.reserved) { + allocation.size = newSize; + this.setResult(oldAddress); + return; + } + + const newAddress = this.allocate(newSize); + const size = Number(allocation.size); + if (size !== 0) { + this.engine.memWrite(newAddress, this.engine.memRead(oldAddress, size)); + } + this.release(oldAddress); + this.setResult(newAddress); + } +} + +function compareBytes(left: Uint8Array, right: Uint8Array): number { + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1; + } + return 0; +} diff --git a/frontend/src/apple/sap/signer.ts b/frontend/src/apple/sap/signer.ts new file mode 100644 index 00000000..774e8c20 --- /dev/null +++ b/frontend/src/apple/sap/signer.ts @@ -0,0 +1,208 @@ +// The SAP setup protocol. +// +// Ported from ipatool's internal/sap/signer_local.go and protocol.go. Setting +// up a signer takes two round trips to Apple and no credentials at all: the +// only identity involved is the device's hardware id, which is the same guid +// the rest of the client already sends in the clear. +// +// Once setup completes, signing is entirely local. + +import { buildPlist } from "../plist"; +import { Machine, type AssetBundle } from "./machine"; + +const SETUP_CERTIFICATE_KEY = "sign-sap-setup-cert"; +const SETUP_BUFFER_KEY = "sign-sap-setup-buffer"; +const MAX_SETUP_BODY = 1 << 20; +const SUPPORTED_VERSION = 200; + +const USER_AGENT = + "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6"; + +export interface SapConfig { + /** Bag key sign-sap-setup. */ + setupURL: string; + /** Bag key sign-sap-setup-cert. */ + certificateURL: string; + /** Bag key sign-sap-version; only 200 is implemented. */ + version: number; + hardwareID: Uint8Array; +} + +/** + * How the setup exchange reaches Apple. The browser has to tunnel it, and + * Node can use fetch directly, so the caller supplies it. + */ +export type Transport = (request: { + method: "GET" | "POST"; + url: string; + headers: Record; + body?: Uint8Array; +}) => Promise; + +function validate(config: SapConfig): void { + if (config.version !== SUPPORTED_VERSION) { + throw new Error(`unsupported SAP version ${config.version}`); + } + if (config.hardwareID.length === 0 || config.hardwareID.length > 20) { + throw new Error("SAP hardware ID must contain between 1 and 20 bytes"); + } + // The endpoints come from the bag, so they are checked rather than trusted. + // Our own origin is allowed whatever scheme it is served over, since these + // requests are proxied through it and a plain-http origin is a development + // setup, not a downgrade of an Apple endpoint. + const origin = + typeof self !== "undefined" && self.location ? self.location.origin : null; + + for (const [label, value] of [ + ["setup", config.setupURL], + ["certificate", config.certificateURL], + ] as const) { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`SAP ${label} URL must be absolute`); + } + if (!url.host || url.username) { + throw new Error(`SAP ${label} URL must be absolute`); + } + if (url.protocol !== "https:" && url.origin !== origin) { + throw new Error(`SAP ${label} URL must use HTTPS`); + } + } +} + +/** + * Pulls one data value out of an Apple plist. + * + * plist.ts parses with DOMParser, which a worker does not have, and the setup + * plists are a single key holding a single base64 blob — so this reads that + * shape directly rather than pulling in an XML parser. + */ +function plistBytes(document: Uint8Array, key: string): Uint8Array { + if (document.length > MAX_SETUP_BODY) { + throw new Error(`Apple response exceeds ${MAX_SETUP_BODY} bytes`); + } + + const xml = new TextDecoder().decode(document); + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp( + `\\s*${escaped}\\s*\\s*([\\s\\S]*?)`, + ).exec(xml); + + if (!match) throw new Error(`Apple plist is missing ${key}`); + + const binary = atob(match[1].replace(/\s+/g, "")); + if (binary.length === 0) throw new Error(`Apple plist is missing ${key}`); + + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; +} + +export class Signer { + private readonly machine: Machine; + private readonly context: bigint; + private readonly hardwareID: Uint8Array; + private closed = false; + + private constructor(machine: Machine, context: bigint, hardwareID: Uint8Array) { + this.machine = machine; + this.context = context; + this.hardwareID = hardwareID; + } + + static async create( + bundle: AssetBundle, + config: SapConfig, + transport: Transport, + ): Promise { + validate(config); + + const machine = await Machine.open(bundle); + let complete = false; + + try { + const context = machine.initialize(config.hardwareID); + + const certificate = plistBytes( + await transport({ + method: "GET", + url: config.certificateURL, + headers: { "User-Agent": USER_AGENT }, + }), + SETUP_CERTIFICATE_KEY, + ); + + const first = machine.exchange( + config.version, + config.hardwareID, + context, + certificate, + ); + if (first.state !== 1) { + throw new Error(`SAP setup entered unexpected state ${first.state}`); + } + if (first.output.length === 0) { + throw new Error("SAP setup message is empty"); + } + + const reply = plistBytes( + await transport({ + method: "POST", + url: config.setupURL, + headers: { + "Content-Type": "application/x-plist", + "User-Agent": USER_AGENT, + }, + body: new TextEncoder().encode( + buildPlist({ [SETUP_BUFFER_KEY]: first.output }), + ), + }), + SETUP_BUFFER_KEY, + ); + + const second = machine.exchange( + config.version, + config.hardwareID, + context, + reply, + ); + if (second.state !== 0) { + throw new Error(`SAP setup completed in unexpected state ${second.state}`); + } + + complete = true; + return new Signer(machine, context, config.hardwareID); + } finally { + if (!complete) machine.close(); + } + } + + /** Signs a request payload. Local, with no network involved. */ + sign(payload: Uint8Array): Uint8Array { + if (this.closed) throw new Error("SAP signer is closed"); + + const signature = this.machine.sign(this.context, payload); + if (signature.length === 0) { + throw new Error("sign Apple request: signature is empty"); + } + + return signature; + } + + close(): void { + if (this.closed) return; + this.closed = true; + + try { + this.machine.teardown(this.context); + } finally { + this.machine.close(); + this.hardwareID.fill(0); + } + } +} diff --git a/frontend/src/apple/sap/unicorn-js.d.ts b/frontend/src/apple/sap/unicorn-js.d.ts new file mode 100644 index 00000000..6440103c --- /dev/null +++ b/frontend/src/apple/sap/unicorn-js.d.ts @@ -0,0 +1,8 @@ +// unicorn.js ships no type declarations. The surface used here is narrow and +// fully exercised by engine.ts, so it is declared as a loose module rather +// than modelled in detail. +declare module "@alexaltea/unicorn-js/x86" { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const factory: () => Promise; + export default factory; +} diff --git a/frontend/src/apple/sap/worker.ts b/frontend/src/apple/sap/worker.ts new file mode 100644 index 00000000..758ebeca --- /dev/null +++ b/frontend/src/apple/sap/worker.ts @@ -0,0 +1,104 @@ +/// +// The SAP signer, off the main thread. +// +// Setting one up runs about ten million emulated instructions, and signing +// runs a few million more: roughly 115 seconds and 12 seconds respectively in +// Chrome on a laptop. Both would freeze the tab, so both happen here, and the +// worker is set up once and kept for the session. + +import { loadAssets, type AssetProgress } from "./assets"; +import { Signer, type Transport } from "./signer"; + +export type WorkerRequest = + | { type: "setup"; hardwareID: Uint8Array; accessToken: string | null } + | { type: "sign"; id: number; payload: Uint8Array }; + +export type WorkerResponse = + | { type: "progress"; phase: "assets"; asset: AssetProgress } + | { type: "progress"; phase: "setup" } + | { type: "ready" } + | { type: "signed"; id: number; signature: Uint8Array } + | { type: "error"; id?: number; message: string }; + +const scope = self as unknown as DedicatedWorkerGlobalScope; + +// A worker has no sessionStorage, so the access token comes in with the +// setup request rather than being read here. +let accessHeaders: Record = {}; + +// The setup endpoints are proxied by the backend; nothing here is secret. +const transport: Transport = async ({ method, url, body }) => { + const response = await fetch(url, { + method, + headers: { + ...accessHeaders, + ...(method === "POST" ? { "Content-Type": "application/x-plist" } : {}), + }, + body: body ? new Blob([body as BlobPart]) : undefined, + }); + + if (!response.ok) { + const detail = await response.json().catch(() => ({})); + throw new Error(detail.error ?? `SAP setup request failed (${response.status})`); + } + + return new Uint8Array(await response.arrayBuffer()); +}; + +let signer: Signer | null = null; + +function post(message: WorkerResponse, transfer?: Transferable[]) { + scope.postMessage(message, transfer ?? []); +} + +async function setup(hardwareID: Uint8Array) { + const bundle = await loadAssets(accessHeaders, (asset) => { + post({ type: "progress", phase: "assets", asset }); + }); + + post({ type: "progress", phase: "setup" }); + + signer = await Signer.create( + bundle, + { + // Routed through the backend rather than to Apple directly, so the + // worker needs no tunnel of its own. + setupURL: new URL("/api/sap/setup", scope.location.origin).toString(), + certificateURL: new URL("/api/sap/certificate", scope.location.origin).toString(), + version: 200, + hardwareID, + }, + transport, + ); + + post({ type: "ready" }); +} + +scope.onmessage = async (event: MessageEvent) => { + const request = event.data; + + try { + if (request.type === "setup") { + if (signer) { + post({ type: "ready" }); + return; + } + accessHeaders = request.accessToken + ? { "X-Access-Token": request.accessToken } + : {}; + await setup(request.hardwareID); + return; + } + + if (!signer) throw new Error("SAP signer is not ready"); + + const signature = signer.sign(request.payload); + post({ type: "signed", id: request.id, signature }, [signature.buffer]); + } catch (error) { + post({ + type: "error", + id: request.type === "sign" ? request.id : undefined, + message: error instanceof Error ? error.message : String(error), + }); + } +}; diff --git a/frontend/src/components/Account/AccountDetail.tsx b/frontend/src/components/Account/AccountDetail.tsx index b5aecd3e..32156ad9 100644 --- a/frontend/src/components/Account/AccountDetail.tsx +++ b/frontend/src/components/Account/AccountDetail.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import PageContainer from "../Layout/PageContainer"; import Spinner from "../common/Spinner"; +import SapStatus from "../common/SapStatus"; import { useAccounts } from "../../hooks/useAccounts"; import { useToastStore } from "../../store/toast"; import { authenticate, AuthenticationError } from "../../apple/authenticate"; @@ -60,6 +61,15 @@ export default function AccountDetail() { async function handleReauth() { if (!account) return; + + // An account imported as a token bundle carries no password. Signing in + // needs one, and trying anyway would spend a minute or two preparing the + // SAP signer before failing on an empty field. + if (!account.password) { + addToast(t("accounts.detail.noPassword"), "error"); + return; + } + setReauthing(true); try { @@ -166,6 +176,7 @@ export default function AccountDetail() { {reauthing && } {t("accounts.detail.verify")} + )} @@ -179,6 +190,7 @@ export default function AccountDetail() { {reauthing && } {t("accounts.detail.reauth")} + {!showDelete ? ( + diff --git a/frontend/src/components/Search/ProductDetail.tsx b/frontend/src/components/Search/ProductDetail.tsx index c70be54e..6aefa7fb 100644 --- a/frontend/src/components/Search/ProductDetail.tsx +++ b/frontend/src/components/Search/ProductDetail.tsx @@ -3,6 +3,7 @@ import { useParams, useLocation, Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import PageContainer from "../Layout/PageContainer"; import AppIcon from "../common/AppIcon"; +import SapStatus from "../common/SapStatus"; import { useAccounts } from "../../hooks/useAccounts"; import { useDownloadAction } from "../../hooks/useDownloadAction"; import { lookupApp } from "../../api/search"; @@ -182,6 +183,9 @@ export default function ProductDetail() { > {t("search.product.versionHistory")} +
+ +
)} diff --git a/frontend/src/components/common/SapStatus.tsx b/frontend/src/components/common/SapStatus.tsx new file mode 100644 index 00000000..cd0bad6e --- /dev/null +++ b/frontend/src/components/common/SapStatus.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from "react-i18next"; +import { useSapStore } from "../../store/sap"; + +/** + * What the SAP signer is doing, for screens with a button that will wait on + * it. Renders nothing when it is idle or ready, so it can be dropped in + * without reserving space for the common case. + * + * The signer starts preparing in the background on load, so this is usually + * showing progress already under way rather than something a press started. + */ +export default function SapStatus() { + const { t } = useTranslation(); + const stage = useSapStore((state) => state.stage); + const percent = useSapStore((state) => state.percent); + const error = useSapStore((state) => state.error); + + if (stage === "idle" || stage === "ready") return null; + + if (stage === "error") { + return ( + + {t("accounts.addForm.signerFailed", { error: error ?? "" })} + + ); + } + + return ( + + {stage === "assets" + ? t("accounts.addForm.preparingAssets", { percent: percent ?? 0 }) + : t("accounts.addForm.preparingSigner")} + + ); +} diff --git a/frontend/src/hooks/useSapWarmup.ts b/frontend/src/hooks/useSapWarmup.ts new file mode 100644 index 00000000..280fced9 --- /dev/null +++ b/frontend/src/hooks/useSapWarmup.ts @@ -0,0 +1,33 @@ +import { useEffect } from "react"; +import { useAccountsStore } from "../store/accounts"; +import { useSapStore } from "../store/sap"; +import { prepareSigner } from "../apple/sap/client"; + +// Starts preparing the SAP signer in the background. +// +// It takes a minute or two — 38 MB of Apple binaries, then ten million +// emulated instructions — so waiting until someone presses a button means +// that button appears dead for the whole of it. Starting on load instead +// means the work is usually done, or well along, by the time it is wanted, +// and a button pressed mid-way can show the progress already being made. +// +// Only started once an account exists, because a signer is bound to the +// hardware id it was initialised with and there is nothing to bind to +// otherwise. A session that never signs in still pays for the assets, which +// is why it waits for that signal rather than firing on first paint. +export function useSapWarmup() { + const accounts = useAccountsStore((state) => state.accounts); + const stage = useSapStore((state) => state.stage); + + useEffect(() => { + if (stage !== "idle") return; + + const device = accounts.find((account) => account.deviceIdentifier) + ?.deviceIdentifier; + if (!device) return; + + // Fire and forget: the store carries the outcome, and a failure here + // should not surface until something actually needs a signature. + prepareSigner(device).catch(() => {}); + }, [accounts, stage]); +} diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 3c5c0e7d..e6f8eb12 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -107,6 +107,10 @@ "codePlaceholder": "000000", "codeHelp": "Enter the verification code from your trusted device.", "signIn": "Sign In", + "preparingAssets": "Preparing signing components {{percent}}%", + "preparingSigner": "Setting up the signer, this takes about a minute…", + "signingRequest": "Signing the request…", + "signerFailed": "Could not prepare the signer: {{error}}", "verify": "Verify", "cancel": "Cancel", "authFailed": "Authentication failed", @@ -128,6 +132,7 @@ "code": "2FA Verification Code", "verify": "Verify", "reauth": "Re-authenticate", + "noPassword": "This account was imported as a token bundle and has no password to sign in with. Remove it and add it again with an email and password.", "delete": "Delete Account", "confirmDelete": "Confirm Delete", "cancel": "Cancel", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 92346932..e6eb8c19 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -107,6 +107,10 @@ "codePlaceholder": "000000", "codeHelp": "信頼できるデバイスからの確認コードを入力してください。", "signIn": "サインイン", + "preparingAssets": "署名コンポーネントを準備中 {{percent}}%", + "preparingSigner": "署名機能を初期化しています。1分ほどかかります…", + "signingRequest": "リクエストに署名しています…", + "signerFailed": "署名コンポーネントを準備できませんでした: {{error}}", "verify": "確認", "cancel": "キャンセル", "authFailed": "認証に失敗しました", @@ -128,6 +132,7 @@ "code": "2ファクタ認証コード", "verify": "確認", "reauth": "再認証", + "noPassword": "このアカウントはトークンとしてインポートされたため、サインインに使うパスワードがありません。削除して、メールアドレスとパスワードで追加し直してください。", "delete": "アカウントを削除", "confirmDelete": "削除を確認", "cancel": "キャンセル", @@ -417,7 +422,7 @@ "purchase": { "paidNotSupported": "有料アプリの購入はサポートされていません", "unavailable": "項目は一時的に利用できません", - "passwordExpired": "パスワードトークンの期限が切れています", + "passwordExpired": "パスワードトークンの有効期限が切れています", "subscriptionRequired": "サブスクリプションが必要です", "termsRequired": "利用規約に同意する必要があります: {{url}}", "failed": "購入失敗: {{failureType}}", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index bb748736..4bbce9bb 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -107,6 +107,10 @@ "codePlaceholder": "000000", "codeHelp": "신뢰할 수 있는 기기에서 확인 코드를 입력하세요.", "signIn": "로그인", + "preparingAssets": "서명 구성요소 준비 중 {{percent}}%", + "preparingSigner": "서명기를 초기화하는 중입니다. 1분 정도 걸립니다…", + "signingRequest": "요청에 서명하는 중…", + "signerFailed": "서명 구성요소를 준비하지 못했습니다: {{error}}", "verify": "확인", "cancel": "취소", "authFailed": "인증 실패", @@ -128,6 +132,7 @@ "code": "2단계 인증 코드", "verify": "확인", "reauth": "재인증", + "noPassword": "이 계정은 토큰 번들로 가져온 것이라 로그인에 쓸 비밀번호가 없습니다. 삭제한 뒤 이메일과 비밀번호로 다시 추가하세요.", "delete": "계정 삭제", "confirmDelete": "삭제 확인", "cancel": "취소", diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index b0e6e6bb..89ee0702 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -107,6 +107,10 @@ "codePlaceholder": "000000", "codeHelp": "Введите код проверки с вашего доверенного устройства.", "signIn": "Войти", + "preparingAssets": "Подготовка компонентов подписи {{percent}}%", + "preparingSigner": "Инициализация подписи, это занимает около минуты…", + "signingRequest": "Подписывание запроса…", + "signerFailed": "Не удалось подготовить подпись: {{error}}", "verify": "Проверить", "cancel": "Отмена", "authFailed": "Ошибка аутентификации", @@ -128,6 +132,7 @@ "code": "Код 2FA", "verify": "Проверить", "reauth": "Обновить сессию", + "noPassword": "Эта учётная запись импортирована как набор токенов и не содержит пароля для входа. Удалите её и добавьте заново с почтой и паролем.", "delete": "Удалить аккаунт", "confirmDelete": "Подтвердить удаление", "cancel": "Отмена", @@ -417,7 +422,7 @@ "purchase": { "paidNotSupported": "Покупка платных приложений не поддерживается", "unavailable": "Элемент временно недоступен", - "passwordExpired": "Токен пароля истек", + "passwordExpired": "Срок действия токена пароля истёк", "subscriptionRequired": "Требуется подписка", "termsRequired": "Вы должны принять Условия и положения: {{url}}", "failed": "Ошибка покупки: {{failureType}}", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 0f54a77c..39f53b28 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -107,6 +107,10 @@ "codePlaceholder": "000000", "codeHelp": "输入来自您受信任设备的验证码。", "signIn": "登录", + "preparingAssets": "正在准备签名组件 {{percent}}%", + "preparingSigner": "正在初始化签名器,约需一分钟…", + "signingRequest": "正在签名请求…", + "signerFailed": "签名组件准备失败:{{error}}", "verify": "验证", "cancel": "取消", "authFailed": "认证失败", @@ -128,6 +132,7 @@ "code": "双重认证代码", "verify": "验证", "reauth": "重新认证", + "noPassword": "该账号是以令牌方式导入的,没有密码可用于登录。请删除后用邮箱和密码重新添加。", "delete": "删除账号", "confirmDelete": "确认删除", "cancel": "取消", diff --git a/frontend/src/locales/zh-TW.json b/frontend/src/locales/zh-TW.json index dad7a1df..6f0462ea 100644 --- a/frontend/src/locales/zh-TW.json +++ b/frontend/src/locales/zh-TW.json @@ -107,6 +107,10 @@ "codePlaceholder": "000000", "codeHelp": "輸入來自您受信任裝置的驗證碼。", "signIn": "登入", + "preparingAssets": "正在準備簽名元件 {{percent}}%", + "preparingSigner": "正在初始化簽名器,約需一分鐘…", + "signingRequest": "正在簽署請求…", + "signerFailed": "簽名元件準備失敗:{{error}}", "verify": "驗證", "cancel": "取消", "authFailed": "認證失敗", @@ -128,6 +132,7 @@ "code": "雙重認證代碼", "verify": "驗證", "reauth": "重新認證", + "noPassword": "該帳號是以權杖方式匯入的,沒有密碼可用於登入。請刪除後用電子郵件和密碼重新新增。", "delete": "刪除帳號", "confirmDelete": "確認刪除", "cancel": "取消", diff --git a/frontend/src/store/sap.ts b/frontend/src/store/sap.ts new file mode 100644 index 00000000..bbc3384a --- /dev/null +++ b/frontend/src/store/sap.ts @@ -0,0 +1,54 @@ +import { create } from "zustand"; + +// Readiness of the SAP signer, shared so any screen can show what it is doing. +// +// Preparing it takes a minute or two — 38 MB of Apple binaries and ten million +// emulated instructions — so it starts on its own in the background as soon as +// there is an account to prepare it for. A button pressed while that is under +// way shows the progress already being made instead of starting over in +// silence. + +export type SapStage = "idle" | "assets" | "setup" | "ready" | "error"; + +interface SapStore { + stage: SapStage; + /** 0 to 100 while assets download, null once past that. */ + percent: number | null; + error: string | null; + /** The hardware id the signer was prepared for. */ + hardwareID: string | null; + + begin: (hardwareID: string) => void; + setAssets: (percent: number) => void; + setSetup: () => void; + setReady: () => void; + setError: (message: string) => void; +} + +export const useSapStore = create((set) => ({ + stage: "idle", + percent: null, + error: null, + hardwareID: null, + + begin: (hardwareID) => + set({ stage: "assets", percent: 0, error: null, hardwareID }), + setAssets: (percent) => set({ stage: "assets", percent }), + setSetup: () => set({ stage: "setup", percent: null }), + setReady: () => set({ stage: "ready", percent: null, error: null }), + setError: (message) => set({ stage: "error", percent: null, error: message }), +})); + +/** A human-readable line for the current stage, or null when there is nothing to say. */ +export function sapStatusKey(stage: SapStage): string | null { + switch (stage) { + case "assets": + return "accounts.addForm.preparingAssets"; + case "setup": + return "accounts.addForm.preparingSigner"; + case "error": + return "accounts.addForm.signerFailed"; + default: + return null; + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 687769ba..12d7fdcb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,9 @@ import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [react(), tailwindcss()], + // The SAP signer worker loads unicorn.js dynamically, and a code-splitting + // build cannot emit that as IIFE. + worker: { format: "es" }, server: { proxy: { "/api": "http://localhost:8080", diff --git a/tools/fetch-sap-assets.mjs b/tools/fetch-sap-assets.mjs new file mode 100644 index 00000000..2d57d8ff --- /dev/null +++ b/tools/fetch-sap-assets.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +// Populates DATA_DIR/sap with the four Apple binaries the browser-side SAP +// signer runs under emulation. +// +// The backend fetches these from Apple on its own the first time the signer +// asks for them, so this script is only a shortcut: if ipatool has already +// cached them on this machine, copying is faster than downloading 38 MB out +// of a 2013 update package again. +// +// The files carry no credentials and are identical for everyone; they are +// kept out of the image because they are large and belong to Apple. +// +// node tools/fetch-sap-assets.mjs [--data-dir ./mnt/asspp-data] + +import { copyFile, mkdir, stat } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; + +const ASSETS = [ + { + name: "CommerceKit", + size: 3271840, + sha256: "b84ff12c21987856c0a17b78f1ad82b73195a6dec5f3b208a17d245555a2c8a2", + }, + { + name: "CommerceCore", + size: 207744, + sha256: "c5401e57402230f3c876409d295319ddf1e61287bc882683c5d61277be7bc1f2", + }, + { + name: "CoreFP", + size: 29014912, + sha256: "f19141336be4198d0f8991bb00017c915efc7aeaece36c345f7faa1237ea6074", + }, + { + name: "CoreFP.icxs", + size: 5288352, + sha256: "473e78af86979f5bd4f6269561caf770b3d16c098d918846eeac8cdd2fe6566a", + }, +]; + +/** Where ipatool caches them, per os.UserCacheDir on each platform. */ +function ipatoolCache() { + const relative = join("ipatool", "sap", "apple-assets-v2"); + + if (platform() === "darwin") { + return join(homedir(), "Library", "Caches", relative); + } + if (platform() === "win32") { + return join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), relative); + } + return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), relative); +} + +async function digest(file) { + return createHash("sha256").update(await readFile(file)).digest("hex"); +} + +async function main() { + const flag = process.argv.indexOf("--data-dir"); + const dataDir = flag === -1 ? process.env.DATA_DIR ?? "./mnt/asspp-data" : process.argv[flag + 1]; + const target = join(dataDir, "sap"); + const source = ipatoolCache(); + + await mkdir(target, { recursive: true }); + + let copied = 0; + let present = 0; + const missing = []; + + for (const asset of ASSETS) { + const destination = join(target, asset.name); + + try { + if ((await stat(destination)).size === asset.size) { + present++; + continue; + } + } catch { + // not there yet + } + + const origin = join(source, asset.name); + try { + if ((await stat(origin)).size !== asset.size) throw new Error("wrong size"); + } catch { + missing.push(asset.name); + continue; + } + + const actual = await digest(origin); + if (actual !== asset.sha256) { + console.error(`${asset.name}: digest mismatch, refusing to copy`); + console.error(` expected ${asset.sha256}`); + console.error(` found ${actual}`); + missing.push(asset.name); + continue; + } + + await copyFile(origin, destination); + console.log(`copied ${asset.name} (${(asset.size / 1048576).toFixed(1)} MB)`); + copied++; + } + + if (present) console.log(`${present} already present`); + + if (missing.length === 0) { + console.log(`\nSAP assets ready in ${target}`); + return; + } + + console.error(`\nmissing: ${missing.join(", ")}`); + console.error(`\nThey were not found in ${source}, which is fine — the`); + console.error("backend downloads them from Apple the first time the signer"); + console.error("runs. This script only saves that download when ipatool has"); + console.error("already cached them here."); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/tools/webkit-check.mjs b/tools/webkit-check.mjs new file mode 100644 index 00000000..8c275d40 --- /dev/null +++ b/tools/webkit-check.mjs @@ -0,0 +1,90 @@ +// Runs the SAP signer in Playwright's WebKit — the engine Safari uses — to +// find out whether it works there at all, and under an iPhone's viewport and +// user agent. +// +// This does not answer the memory question: a desktop WebKit has far more +// headroom than a phone. It answers the other half — whether WebAssembly, +// dedicated workers, the Cache API and the rest behave the way the signer +// needs them to on WebKit rather than on Chrome. +// +// Playwright is not a dependency of this project. ESM resolves imports from +// this file's own directory rather than the working directory, so it has to +// be installed at the repo root — installing it under frontend/ does not help +// however the script is invoked. With the dev servers already up: +// +// npm install --no-save playwright && npx playwright install webkit +// node tools/webkit-check.mjs + +import { webkit, devices } from "playwright"; + +const TARGET = process.env.TARGET ?? "http://localhost:5173/"; +const BUDGET_MS = 6 * 60 * 1000; + +const browser = await webkit.launch(); +const context = await browser.newContext(devices["iPhone 15"]); +const page = await context.newPage(); + +const consoleErrors = []; +page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); +}); +page.on("pageerror", (error) => consoleErrors.push(String(error.message))); + +console.log(`WebKit ${browser.version()}, iPhone 15 profile`); +console.log(`opening ${TARGET}`); +await page.goto(TARGET, { waitUntil: "domcontentloaded" }); + +const result = await page.evaluate(async () => { + const marks = []; + const t0 = performance.now(); + const at = () => Math.round(performance.now() - t0); + + const capability = { + webassembly: typeof WebAssembly === "object", + worker: typeof Worker === "function", + caches: typeof caches === "object", + bigint: typeof BigInt === "function", + }; + + try { + const mod = await import("/src/apple/sap/client.ts"); + let last = ""; + + await mod.prepareSigner("020000000000", (progress) => { + if (progress.phase !== last) { + last = progress.phase; + marks.push(`${progress.phase}@${at()}ms`); + } + }); + + const setupMs = at(); + const s0 = performance.now(); + const signature = await mod.signAction(new TextEncoder().encode("")); + + return { + ok: true, + capability, + marks, + setupMs, + signMs: Math.round(performance.now() - s0), + signatureBytes: signature.length, + }; + } catch (error) { + return { + ok: false, + capability, + marks, + error: String((error && error.message) || error), + }; + } +}, { timeout: BUDGET_MS }); + +console.log(); +console.log(JSON.stringify(result, null, 2)); +if (consoleErrors.length) { + console.log("\nconsole errors:"); + for (const line of consoleErrors.slice(0, 8)) console.log(` ${line}`); +} + +await browser.close(); +process.exitCode = result.ok ? 0 : 1;