feat: Implement Anisette JS/TS API with WASM support
- Added main Anisette class for high-level API. - Introduced device management with Device class. - Created HTTP client abstraction for network requests. - Implemented provisioning session handling with ProvisioningSession class. - Added utility functions for encoding, decoding, and random generation. - Established library management with LibraryStore class. - Integrated WASM loading and bridging with WasmBridge. - Defined core types and interfaces for the API. - Set up TypeScript configuration and build scripts. - Updated package.json for new build and run commands. - Added bun.lock and package.json for JS dependencies. - Enhanced error handling and memory management in Rust code.
This commit is contained in:
15
js/bun.lock
Normal file
15
js/bun.lock
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "anisette-js",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
}
|
||||
}
|
||||
22
js/package.json
Normal file
22
js/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "anisette-js",
|
||||
"version": "0.1.0",
|
||||
"description": "High-level JavaScript/TypeScript API for Apple Anisette headers via WASM",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts --outfile dist/anisette.js --target node --format esm --minify",
|
||||
"build:cjs": "bun build src/index.ts --outfile dist/anisette.cjs --target node --format cjs --minify",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
213
js/src/anisette.ts
Normal file
213
js/src/anisette.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
// Main Anisette class — the public-facing API
|
||||
|
||||
import type { AnisetteDeviceConfig, AnisetteHeaders, InitOptions } from "./types.js";
|
||||
import type { HttpClient } from "./http.js";
|
||||
import { WasmBridge } from "./wasm-bridge.js";
|
||||
import { Device } from "./device.js";
|
||||
import { LibraryStore } from "./library.js";
|
||||
import { ProvisioningSession } from "./provisioning.js";
|
||||
import {
|
||||
toBase64,
|
||||
toAppleClientTime,
|
||||
detectLocale,
|
||||
encodeUtf8,
|
||||
} from "./utils.js";
|
||||
import type { DeviceJson } from "./types.js";
|
||||
|
||||
const DEFAULT_DSID = BigInt(-2);
|
||||
const DEFAULT_LIBRARY_PATH = "./anisette/";
|
||||
const MD_RINFO = "17106176";
|
||||
|
||||
export interface AnisetteOptions {
|
||||
/** Override the HTTP client (useful for testing or custom proxy) */
|
||||
httpClient?: HttpClient;
|
||||
/** DSID to use when requesting OTP (default: -2) */
|
||||
dsid?: bigint;
|
||||
/** Options passed to WASM init */
|
||||
init?: InitOptions;
|
||||
}
|
||||
|
||||
export class Anisette {
|
||||
private bridge: WasmBridge;
|
||||
private device: Device;
|
||||
private libs: LibraryStore;
|
||||
private provisioning: ProvisioningSession;
|
||||
private dsid: bigint;
|
||||
private libraryPath: string;
|
||||
|
||||
private constructor(
|
||||
bridge: WasmBridge,
|
||||
device: Device,
|
||||
libs: LibraryStore,
|
||||
provisioning: ProvisioningSession,
|
||||
dsid: bigint,
|
||||
libraryPath: string
|
||||
) {
|
||||
this.bridge = bridge;
|
||||
this.device = device;
|
||||
this.libs = libs;
|
||||
this.provisioning = provisioning;
|
||||
this.dsid = dsid;
|
||||
this.libraryPath = libraryPath;
|
||||
}
|
||||
|
||||
// ---- factory methods ----
|
||||
|
||||
/**
|
||||
* Initialize from the two Android .so library files.
|
||||
* @param storeservicescore - bytes of libstoreservicescore.so
|
||||
* @param coreadi - bytes of libCoreADI.so
|
||||
*/
|
||||
static async fromSo(
|
||||
storeservicescore: Uint8Array,
|
||||
coreadi: Uint8Array,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wasmModule: any,
|
||||
options: AnisetteOptions = {}
|
||||
): Promise<Anisette> {
|
||||
const libs = LibraryStore.fromBlobs(storeservicescore, coreadi);
|
||||
return Anisette._init(libs, wasmModule, options);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private static async _init(
|
||||
libs: LibraryStore,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wasmModule: any,
|
||||
options: AnisetteOptions
|
||||
): Promise<Anisette> {
|
||||
const bridge = new WasmBridge(wasmModule);
|
||||
const initOpts = options.init ?? {};
|
||||
const libraryPath = initOpts.libraryPath ?? DEFAULT_LIBRARY_PATH;
|
||||
const provisioningPath = initOpts.provisioningPath ?? libraryPath;
|
||||
const dsid = options.dsid ?? DEFAULT_DSID;
|
||||
|
||||
// Load or generate device config
|
||||
const device = Device.fromJson(null, initOpts.deviceConfig);
|
||||
|
||||
// Write device.json into WASM VFS so the emulator can read it
|
||||
const deviceJson = device.toJson();
|
||||
const deviceJsonBytes = encodeUtf8(JSON.stringify(deviceJson, null, 2));
|
||||
bridge.writeVirtualFile(joinPath(libraryPath, "device.json"), deviceJsonBytes);
|
||||
|
||||
// Initialize WASM ADI
|
||||
bridge.initFromBlobs(
|
||||
libs.storeservicescore,
|
||||
libs.coreadi,
|
||||
libraryPath,
|
||||
provisioningPath,
|
||||
initOpts.identifier ?? device.adiIdentifier
|
||||
);
|
||||
|
||||
const provisioning = new ProvisioningSession(
|
||||
bridge,
|
||||
device,
|
||||
options.httpClient
|
||||
);
|
||||
|
||||
return new Anisette(bridge, device, libs, provisioning, dsid, libraryPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a previously saved session (device.json + adi.pb written back into VFS).
|
||||
* Pass the saved device.json and adi.pb bytes alongside the library blobs.
|
||||
*/
|
||||
static async fromSaved(
|
||||
storeservicescore: Uint8Array,
|
||||
coreadi: Uint8Array,
|
||||
deviceJsonBytes: Uint8Array,
|
||||
adiPbBytes: Uint8Array,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wasmModule: any,
|
||||
options: AnisetteOptions = {}
|
||||
): Promise<Anisette> {
|
||||
const bridge = new WasmBridge(wasmModule);
|
||||
const initOpts = options.init ?? {};
|
||||
const libraryPath = initOpts.libraryPath ?? DEFAULT_LIBRARY_PATH;
|
||||
const provisioningPath = initOpts.provisioningPath ?? libraryPath;
|
||||
const dsid = options.dsid ?? DEFAULT_DSID;
|
||||
|
||||
// Parse saved device config
|
||||
let deviceJson: DeviceJson | null = null;
|
||||
try {
|
||||
deviceJson = JSON.parse(new TextDecoder().decode(deviceJsonBytes)) as DeviceJson;
|
||||
} catch {
|
||||
// ignore parse errors — will generate fresh device
|
||||
}
|
||||
const device = Device.fromJson(deviceJson, initOpts.deviceConfig);
|
||||
|
||||
// Restore VFS files
|
||||
bridge.writeVirtualFile(joinPath(libraryPath, "device.json"), deviceJsonBytes);
|
||||
bridge.writeVirtualFile(joinPath(libraryPath, "adi.pb"), adiPbBytes);
|
||||
|
||||
const libs = LibraryStore.fromBlobs(storeservicescore, coreadi);
|
||||
|
||||
bridge.initFromBlobs(
|
||||
libs.storeservicescore,
|
||||
libs.coreadi,
|
||||
libraryPath,
|
||||
provisioningPath,
|
||||
initOpts.identifier ?? device.adiIdentifier
|
||||
);
|
||||
|
||||
const provisioning = new ProvisioningSession(
|
||||
bridge,
|
||||
device,
|
||||
options.httpClient
|
||||
);
|
||||
|
||||
return new Anisette(bridge, device, libs, provisioning, dsid, libraryPath);
|
||||
}
|
||||
|
||||
// ---- public API ----
|
||||
|
||||
/** Whether the device is currently provisioned. */
|
||||
get isProvisioned(): boolean {
|
||||
return this.bridge.isMachineProvisioned(this.dsid);
|
||||
}
|
||||
|
||||
/** Run the provisioning flow against Apple servers. */
|
||||
async provision(): Promise<void> {
|
||||
await this.provisioning.provision(this.dsid);
|
||||
}
|
||||
|
||||
/** Generate Anisette headers. Throws if not provisioned. */
|
||||
async getData(): Promise<AnisetteHeaders> {
|
||||
const { otp, machineId } = this.bridge.requestOtp(this.dsid);
|
||||
|
||||
const now = new Date();
|
||||
const tzOffset = -now.getTimezoneOffset();
|
||||
const tzSign = tzOffset >= 0 ? "+" : "-";
|
||||
const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, "0");
|
||||
const tzMins = String(Math.abs(tzOffset) % 60).padStart(2, "0");
|
||||
const timezone = `${tzSign}${tzHours}${tzMins}`;
|
||||
|
||||
return {
|
||||
"X-Apple-I-Client-Time": toAppleClientTime(now),
|
||||
"X-Apple-I-MD": toBase64(otp),
|
||||
"X-Apple-I-MD-LU": this.device.localUserUuid,
|
||||
"X-Apple-I-MD-M": toBase64(machineId),
|
||||
"X-Apple-I-MD-RINFO": MD_RINFO,
|
||||
"X-Apple-I-SRL-NO": "0",
|
||||
"X-Apple-I-TimeZone": timezone,
|
||||
"X-Apple-Locale": detectLocale(),
|
||||
"X-MMe-Client-Info": this.device.serverFriendlyDescription,
|
||||
"X-Mme-Device-Id": this.device.uniqueDeviceIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize device.json bytes for persistence. */
|
||||
getDeviceJson(): Uint8Array {
|
||||
return encodeUtf8(JSON.stringify(this.device.toJson(), null, 2));
|
||||
}
|
||||
|
||||
/** Expose the device for inspection. */
|
||||
getDevice(): Device {
|
||||
return this.device;
|
||||
}
|
||||
}
|
||||
|
||||
function joinPath(base: string, file: string): string {
|
||||
const b = base.endsWith("/") ? base : `${base}/`;
|
||||
return `${b}${file}`;
|
||||
}
|
||||
64
js/src/device.ts
Normal file
64
js/src/device.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Device identity management — loads or generates device.json
|
||||
|
||||
import type { AnisetteDeviceConfig, DeviceJson } from "./types.js";
|
||||
import { randomHex, randomUUID } from "./utils.js";
|
||||
|
||||
const DEFAULT_CLIENT_INFO =
|
||||
"<MacBookPro13,2> <macOS;13.1;22C65> <com.apple.AuthKit/1 (com.apple.dt.Xcode/3594.4.19)>";
|
||||
|
||||
export class Device {
|
||||
readonly uniqueDeviceIdentifier: string;
|
||||
readonly serverFriendlyDescription: string;
|
||||
readonly adiIdentifier: string;
|
||||
readonly localUserUuid: string;
|
||||
|
||||
private constructor(data: DeviceJson) {
|
||||
this.uniqueDeviceIdentifier = data.UUID;
|
||||
this.serverFriendlyDescription = data.clientInfo;
|
||||
this.adiIdentifier = data.identifier;
|
||||
this.localUserUuid = data.localUUID;
|
||||
}
|
||||
|
||||
/** Load from a parsed device.json object, or generate defaults if null. */
|
||||
static fromJson(
|
||||
json: DeviceJson | null,
|
||||
overrides?: Partial<AnisetteDeviceConfig>
|
||||
): Device {
|
||||
const defaults = Device.generateDefaults();
|
||||
const base: DeviceJson = json ?? {
|
||||
UUID: defaults.uniqueDeviceId,
|
||||
clientInfo: defaults.serverFriendlyDescription,
|
||||
identifier: defaults.adiId,
|
||||
localUUID: defaults.localUserUuid,
|
||||
};
|
||||
|
||||
if (overrides) {
|
||||
if (overrides.uniqueDeviceId) base.UUID = overrides.uniqueDeviceId;
|
||||
if (overrides.serverFriendlyDescription)
|
||||
base.clientInfo = overrides.serverFriendlyDescription;
|
||||
if (overrides.adiId) base.identifier = overrides.adiId;
|
||||
if (overrides.localUserUuid) base.localUUID = overrides.localUserUuid;
|
||||
}
|
||||
|
||||
return new Device(base);
|
||||
}
|
||||
|
||||
/** Serialize back to the device.json wire format. */
|
||||
toJson(): DeviceJson {
|
||||
return {
|
||||
UUID: this.uniqueDeviceIdentifier,
|
||||
clientInfo: this.serverFriendlyDescription,
|
||||
identifier: this.adiIdentifier,
|
||||
localUUID: this.localUserUuid,
|
||||
};
|
||||
}
|
||||
|
||||
static generateDefaults(): AnisetteDeviceConfig {
|
||||
return {
|
||||
serverFriendlyDescription: DEFAULT_CLIENT_INFO,
|
||||
uniqueDeviceId: randomUUID(),
|
||||
adiId: randomHex(8, false),
|
||||
localUserUuid: randomHex(32, true),
|
||||
};
|
||||
}
|
||||
}
|
||||
32
js/src/http.ts
Normal file
32
js/src/http.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// HTTP client abstraction — allows swapping fetch vs Node.js http in tests
|
||||
|
||||
export interface HttpClient {
|
||||
get(url: string, headers: Record<string, string>): Promise<Uint8Array>;
|
||||
post(
|
||||
url: string,
|
||||
body: string,
|
||||
headers: Record<string, string>
|
||||
): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export class FetchHttpClient implements HttpClient {
|
||||
async get(url: string, headers: Record<string, string>): Promise<Uint8Array> {
|
||||
const response = await fetch(url, { method: "GET", headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP GET ${url} failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async post(
|
||||
url: string,
|
||||
body: string,
|
||||
headers: Record<string, string>
|
||||
): Promise<Uint8Array> {
|
||||
const response = await fetch(url, { method: "POST", body, headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP POST ${url} failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
}
|
||||
17
js/src/index.ts
Normal file
17
js/src/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Public entry point — re-exports everything users need
|
||||
|
||||
export { Anisette } from "./anisette.js";
|
||||
export { loadWasm } from "./wasm-loader.js";
|
||||
export { WasmBridge } from "./wasm-bridge.js";
|
||||
export { Device } from "./device.js";
|
||||
export { LibraryStore } from "./library.js";
|
||||
export { ProvisioningSession } from "./provisioning.js";
|
||||
export { FetchHttpClient } from "./http.js";
|
||||
export type { HttpClient } from "./http.js";
|
||||
export type {
|
||||
AnisetteHeaders,
|
||||
AnisetteDeviceConfig,
|
||||
InitOptions,
|
||||
DeviceJson,
|
||||
} from "./types.js";
|
||||
export type { AnisetteOptions } from "./anisette.js";
|
||||
40
js/src/library.ts
Normal file
40
js/src/library.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// LibraryStore — holds the two required Android .so blobs
|
||||
|
||||
const REQUIRED_LIBS = [
|
||||
"libstoreservicescore.so",
|
||||
"libCoreADI.so",
|
||||
] as const;
|
||||
|
||||
export type LibraryName = (typeof REQUIRED_LIBS)[number];
|
||||
|
||||
export class LibraryStore {
|
||||
private libs: Map<LibraryName, Uint8Array>;
|
||||
|
||||
private constructor(libs: Map<LibraryName, Uint8Array>) {
|
||||
this.libs = libs;
|
||||
}
|
||||
|
||||
static fromBlobs(
|
||||
storeservicescore: Uint8Array,
|
||||
coreadi: Uint8Array
|
||||
): LibraryStore {
|
||||
const map = new Map<LibraryName, Uint8Array>();
|
||||
map.set("libstoreservicescore.so", storeservicescore);
|
||||
map.set("libCoreADI.so", coreadi);
|
||||
return new LibraryStore(map);
|
||||
}
|
||||
|
||||
get(name: LibraryName): Uint8Array {
|
||||
const data = this.libs.get(name);
|
||||
if (!data) throw new Error(`Library not loaded: ${name}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
get storeservicescore(): Uint8Array {
|
||||
return this.get("libstoreservicescore.so");
|
||||
}
|
||||
|
||||
get coreadi(): Uint8Array {
|
||||
return this.get("libCoreADI.so");
|
||||
}
|
||||
}
|
||||
159
js/src/provisioning.ts
Normal file
159
js/src/provisioning.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// ProvisioningSession — communicates with Apple servers to provision the device
|
||||
|
||||
import { fromBase64, toBase64, toAppleClientTime } from "./utils.js";
|
||||
import type { WasmBridge } from "./wasm-bridge.js";
|
||||
import type { Device } from "./device.js";
|
||||
import type { HttpClient } from "./http.js";
|
||||
import { FetchHttpClient } from "./http.js";
|
||||
|
||||
const LOOKUP_URL = "https://gsa.apple.com/grandslam/GsService2/lookup";
|
||||
|
||||
const START_PROVISIONING_BODY = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Header</key>
|
||||
<dict/>
|
||||
<key>Request</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</plist>`;
|
||||
|
||||
export class ProvisioningSession {
|
||||
private bridge: WasmBridge;
|
||||
private device: Device;
|
||||
private http: HttpClient;
|
||||
private urlBag: Record<string, string> = {};
|
||||
|
||||
constructor(bridge: WasmBridge, device: Device, http?: HttpClient) {
|
||||
this.bridge = bridge;
|
||||
this.device = device;
|
||||
this.http = http ?? new FetchHttpClient();
|
||||
}
|
||||
|
||||
async provision(dsid: bigint): Promise<void> {
|
||||
if (Object.keys(this.urlBag).length === 0) {
|
||||
await this.loadUrlBag();
|
||||
}
|
||||
|
||||
const startUrl = this.urlBag["midStartProvisioning"];
|
||||
const finishUrl = this.urlBag["midFinishProvisioning"];
|
||||
if (!startUrl) throw new Error("url bag missing midStartProvisioning");
|
||||
if (!finishUrl) throw new Error("url bag missing midFinishProvisioning");
|
||||
|
||||
// Step 1: get SPIM from Apple
|
||||
const startBytes = await this.http.post(
|
||||
startUrl,
|
||||
START_PROVISIONING_BODY,
|
||||
this.commonHeaders(true)
|
||||
);
|
||||
const startPlist = parsePlist(startBytes);
|
||||
const spimB64 = plistGetStringInResponse(startPlist, "spim");
|
||||
const spim = fromBase64(spimB64);
|
||||
|
||||
// Step 2: call WASM start_provisioning
|
||||
const { cpim, session } = this.bridge.startProvisioning(dsid, spim);
|
||||
const cpimB64 = toBase64(cpim);
|
||||
|
||||
// Step 3: send CPIM to Apple, get PTM + TK
|
||||
const finishBody = buildFinishBody(cpimB64);
|
||||
const finishBytes = await this.http.post(
|
||||
finishUrl,
|
||||
finishBody,
|
||||
this.commonHeaders(true)
|
||||
);
|
||||
const finishPlist = parsePlist(finishBytes);
|
||||
const ptm = fromBase64(plistGetStringInResponse(finishPlist, "ptm"));
|
||||
const tk = fromBase64(plistGetStringInResponse(finishPlist, "tk"));
|
||||
|
||||
// Step 4: finalize provisioning in WASM
|
||||
this.bridge.endProvisioning(session, ptm, tk);
|
||||
}
|
||||
|
||||
private async loadUrlBag(): Promise<void> {
|
||||
const bytes = await this.http.get(LOOKUP_URL, this.commonHeaders(false));
|
||||
const plist = parsePlist(bytes);
|
||||
const urls = plistGetDict(plist, "urls");
|
||||
this.urlBag = {};
|
||||
for (const [k, v] of Object.entries(urls)) {
|
||||
if (typeof v === "string") this.urlBag[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
private commonHeaders(includeTime: boolean): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": "akd/1.0 CFNetwork/1404.0.5 Darwin/22.3.0",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Connection: "keep-alive",
|
||||
"X-Mme-Device-Id": this.device.uniqueDeviceIdentifier,
|
||||
"X-MMe-Client-Info": this.device.serverFriendlyDescription,
|
||||
"X-Apple-I-MD-LU": this.device.localUserUuid,
|
||||
"X-Apple-Client-App-Name": "Setup",
|
||||
};
|
||||
if (includeTime) {
|
||||
headers["X-Apple-I-Client-Time"] = toAppleClientTime();
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- minimal plist XML parser ----
|
||||
// We only need to extract string values from Apple's response plists.
|
||||
|
||||
interface PlistDict {
|
||||
[key: string]: string | PlistDict;
|
||||
}
|
||||
|
||||
function parsePlist(bytes: Uint8Array): PlistDict {
|
||||
const xml = new TextDecoder("utf-8").decode(bytes);
|
||||
return parsePlistDict(xml);
|
||||
}
|
||||
|
||||
function parsePlistDict(xml: string): PlistDict {
|
||||
const result: PlistDict = {};
|
||||
// Match <key>...</key> followed by <string>...</string> or <dict>...</dict>
|
||||
const keyRe = /<key>([^<]*)<\/key>\s*(<string>([^<]*)<\/string>|<dict>([\s\S]*?)<\/dict>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = keyRe.exec(xml)) !== null) {
|
||||
const key = m[1];
|
||||
if (m[3] !== undefined) {
|
||||
result[key] = m[3];
|
||||
} else if (m[4] !== undefined) {
|
||||
result[key] = parsePlistDict(m[4]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function plistGetStringInResponse(plist: PlistDict, key: string): string {
|
||||
const response = plist;
|
||||
const value = (response as PlistDict)[key];
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`plist Response missing string field: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function plistGetDict(plist: PlistDict, key: string): PlistDict {
|
||||
const value = plist[key];
|
||||
if (!value || typeof value === "string") {
|
||||
throw new Error(`plist missing dict field: ${key}`);
|
||||
}
|
||||
return value as PlistDict;
|
||||
}
|
||||
|
||||
function buildFinishBody(cpimB64: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Header</key>
|
||||
<dict/>
|
||||
<key>Request</key>
|
||||
<dict>
|
||||
<key>cpim</key>
|
||||
<string>${cpimB64}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>`;
|
||||
}
|
||||
44
js/src/types.ts
Normal file
44
js/src/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Core type definitions for the Anisette JS/TS API
|
||||
|
||||
export interface AnisetteDeviceConfig {
|
||||
/** Human-readable device description sent to Apple servers */
|
||||
serverFriendlyDescription: string;
|
||||
/** Unique device UUID (uppercase) */
|
||||
uniqueDeviceId: string;
|
||||
/** ADI identifier (hex string) */
|
||||
adiId: string;
|
||||
/** Local user UUID (uppercase hex) */
|
||||
localUserUuid: string;
|
||||
}
|
||||
|
||||
export interface AnisetteHeaders {
|
||||
"X-Apple-I-Client-Time": string;
|
||||
"X-Apple-I-MD": string;
|
||||
"X-Apple-I-MD-LU": string;
|
||||
"X-Apple-I-MD-M": string;
|
||||
"X-Apple-I-MD-RINFO": string;
|
||||
"X-Apple-I-SRL-NO": string;
|
||||
"X-Apple-I-TimeZone": string;
|
||||
"X-Apple-Locale": string;
|
||||
"X-MMe-Client-Info": string;
|
||||
"X-Mme-Device-Id": string;
|
||||
}
|
||||
|
||||
export interface InitOptions {
|
||||
/** Path prefix used inside the WASM virtual filesystem for library files */
|
||||
libraryPath?: string;
|
||||
/** Path prefix used inside the WASM virtual filesystem for provisioning data */
|
||||
provisioningPath?: string;
|
||||
/** ADI identifier override */
|
||||
identifier?: string;
|
||||
/** Override parts of the generated device config */
|
||||
deviceConfig?: Partial<AnisetteDeviceConfig>;
|
||||
}
|
||||
|
||||
/** Raw device.json structure as stored on disk / in WASM VFS */
|
||||
export interface DeviceJson {
|
||||
UUID: string;
|
||||
clientInfo: string;
|
||||
identifier: string;
|
||||
localUUID: string;
|
||||
}
|
||||
91
js/src/utils.ts
Normal file
91
js/src/utils.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// Utility functions shared across modules
|
||||
|
||||
const TEXT_ENCODER = new TextEncoder();
|
||||
const TEXT_DECODER = new TextDecoder("utf-8");
|
||||
|
||||
/** Encode string to UTF-8 bytes */
|
||||
export function encodeUtf8(str: string): Uint8Array {
|
||||
return TEXT_ENCODER.encode(str);
|
||||
}
|
||||
|
||||
/** Decode UTF-8 bytes to string */
|
||||
export function decodeUtf8(bytes: Uint8Array): string {
|
||||
return TEXT_DECODER.decode(bytes);
|
||||
}
|
||||
|
||||
/** Encode bytes to base64 string */
|
||||
export function toBase64(bytes: Uint8Array): string {
|
||||
if (bytes.length === 0) return "";
|
||||
// Works in both browser and Node.js (Node 16+)
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Decode base64 string to bytes */
|
||||
export function fromBase64(b64: string): Uint8Array {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(b64, "base64"));
|
||||
}
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Format a Date as Apple client time string (ISO 8601 without milliseconds) */
|
||||
export function toAppleClientTime(date: Date = new Date()): string {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
/** Detect locale string in Apple format (e.g. "en_US") */
|
||||
export function detectLocale(): string {
|
||||
const locale =
|
||||
(typeof Intl !== "undefined" &&
|
||||
Intl.DateTimeFormat().resolvedOptions().locale) ||
|
||||
"en-US";
|
||||
return locale.replace("-", "_");
|
||||
}
|
||||
|
||||
/** Generate a random hex string of the given byte length */
|
||||
export function randomHex(byteLen: number, uppercase = false): string {
|
||||
const bytes = new Uint8Array(byteLen);
|
||||
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
// Node.js fallback
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodeCrypto = require("crypto") as typeof import("crypto");
|
||||
const buf = nodeCrypto.randomBytes(byteLen);
|
||||
bytes.set(buf);
|
||||
}
|
||||
let hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
return uppercase ? hex.toUpperCase() : hex;
|
||||
}
|
||||
|
||||
/** Generate a random UUID v4 (uppercase) */
|
||||
export function randomUUID(): string {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID().toUpperCase();
|
||||
}
|
||||
// Manual fallback
|
||||
const hex = randomHex(16);
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
"4" + hex.slice(13, 16),
|
||||
((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16) + hex.slice(17, 20),
|
||||
hex.slice(20, 32),
|
||||
]
|
||||
.join("-")
|
||||
.toUpperCase();
|
||||
}
|
||||
202
js/src/wasm-bridge.ts
Normal file
202
js/src/wasm-bridge.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Low-level bridge to the Emscripten-generated WASM module.
|
||||
// Handles all pointer/length marshalling so higher layers never touch raw memory.
|
||||
|
||||
export interface StartProvisioningResult {
|
||||
cpim: Uint8Array;
|
||||
session: number;
|
||||
}
|
||||
|
||||
export interface RequestOtpResult {
|
||||
otp: Uint8Array;
|
||||
machineId: Uint8Array;
|
||||
}
|
||||
|
||||
export class WasmBridge {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private m: any;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(wasmModule: any) {
|
||||
this.m = wasmModule;
|
||||
}
|
||||
|
||||
// ---- memory helpers ----
|
||||
|
||||
private allocBytes(bytes: Uint8Array): number {
|
||||
const ptr = this.m._malloc(bytes.length) as number;
|
||||
this.m.HEAPU8.set(bytes, ptr);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
private allocCString(value: string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const size = (this.m.lengthBytesUTF8(value) as number) + 1;
|
||||
const ptr = this.m._malloc(size) as number;
|
||||
this.m.stringToUTF8(value, ptr, size);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
private readBytes(ptr: number, len: number): Uint8Array {
|
||||
if (!ptr || !len) return new Uint8Array(0);
|
||||
return (this.m.HEAPU8 as Uint8Array).slice(ptr, ptr + len);
|
||||
}
|
||||
|
||||
private free(ptr: number): void {
|
||||
if (ptr) this.m._free(ptr);
|
||||
}
|
||||
|
||||
// ---- error handling ----
|
||||
|
||||
getLastError(): string {
|
||||
const ptr = this.m._anisette_last_error_ptr() as number;
|
||||
const len = this.m._anisette_last_error_len() as number;
|
||||
if (!ptr || !len) return "";
|
||||
const bytes = (this.m.HEAPU8 as Uint8Array).subarray(ptr, ptr + len);
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
|
||||
private check(result: number, context: string): void {
|
||||
if (result !== 0) {
|
||||
const msg = this.getLastError();
|
||||
throw new Error(`${context}: ${msg || "unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- public API ----
|
||||
|
||||
/**
|
||||
* Initialize ADI from in-memory library blobs.
|
||||
*/
|
||||
initFromBlobs(
|
||||
storeservices: Uint8Array,
|
||||
coreadi: Uint8Array,
|
||||
libraryPath: string,
|
||||
provisioningPath?: string,
|
||||
identifier?: string
|
||||
): void {
|
||||
const ssPtr = this.allocBytes(storeservices);
|
||||
const caPtr = this.allocBytes(coreadi);
|
||||
const libPtr = this.allocCString(libraryPath);
|
||||
const provPtr = this.allocCString(provisioningPath ?? null);
|
||||
const idPtr = this.allocCString(identifier ?? null);
|
||||
|
||||
try {
|
||||
const result = this.m._anisette_init_from_blobs(
|
||||
ssPtr,
|
||||
storeservices.length,
|
||||
caPtr,
|
||||
coreadi.length,
|
||||
libPtr,
|
||||
provPtr,
|
||||
idPtr
|
||||
) as number;
|
||||
this.check(result, "anisette_init_from_blobs");
|
||||
} finally {
|
||||
this.free(ssPtr);
|
||||
this.free(caPtr);
|
||||
this.free(libPtr);
|
||||
this.free(provPtr);
|
||||
this.free(idPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a file into the WASM virtual filesystem.
|
||||
*/
|
||||
writeVirtualFile(filePath: string, data: Uint8Array): void {
|
||||
const pathPtr = this.allocCString(filePath);
|
||||
const dataPtr = this.allocBytes(data);
|
||||
try {
|
||||
const result = this.m._anisette_fs_write_file(
|
||||
pathPtr,
|
||||
dataPtr,
|
||||
data.length
|
||||
) as number;
|
||||
this.check(result, `anisette_fs_write_file(${filePath})`);
|
||||
} finally {
|
||||
this.free(pathPtr);
|
||||
this.free(dataPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns 1 if provisioned, 0 if not, throws on error.
|
||||
*/
|
||||
isMachineProvisioned(dsid: bigint): boolean {
|
||||
const result = this.m._anisette_is_machine_provisioned(dsid) as number;
|
||||
if (result < 0) {
|
||||
throw new Error(
|
||||
`anisette_is_machine_provisioned: ${this.getLastError()}`
|
||||
);
|
||||
}
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start provisioning — returns CPIM bytes and session handle.
|
||||
*/
|
||||
startProvisioning(
|
||||
dsid: bigint,
|
||||
spim: Uint8Array
|
||||
): StartProvisioningResult {
|
||||
const spimPtr = this.allocBytes(spim);
|
||||
try {
|
||||
const result = this.m._anisette_start_provisioning(
|
||||
dsid,
|
||||
spimPtr,
|
||||
spim.length
|
||||
) as number;
|
||||
this.check(result, "anisette_start_provisioning");
|
||||
} finally {
|
||||
this.free(spimPtr);
|
||||
}
|
||||
|
||||
const cpimPtr = this.m._anisette_get_cpim_ptr() as number;
|
||||
const cpimLen = this.m._anisette_get_cpim_len() as number;
|
||||
const session = this.m._anisette_get_session() as number;
|
||||
|
||||
return {
|
||||
cpim: this.readBytes(cpimPtr, cpimLen),
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish provisioning with PTM and TK from Apple servers.
|
||||
*/
|
||||
endProvisioning(session: number, ptm: Uint8Array, tk: Uint8Array): void {
|
||||
const ptmPtr = this.allocBytes(ptm);
|
||||
const tkPtr = this.allocBytes(tk);
|
||||
try {
|
||||
const result = this.m._anisette_end_provisioning(
|
||||
session,
|
||||
ptmPtr,
|
||||
ptm.length,
|
||||
tkPtr,
|
||||
tk.length
|
||||
) as number;
|
||||
this.check(result, "anisette_end_provisioning");
|
||||
} finally {
|
||||
this.free(ptmPtr);
|
||||
this.free(tkPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request OTP — returns OTP bytes and machine ID bytes.
|
||||
*/
|
||||
requestOtp(dsid: bigint): RequestOtpResult {
|
||||
const result = this.m._anisette_request_otp(dsid) as number;
|
||||
this.check(result, "anisette_request_otp");
|
||||
|
||||
const otpPtr = this.m._anisette_get_otp_ptr() as number;
|
||||
const otpLen = this.m._anisette_get_otp_len() as number;
|
||||
const midPtr = this.m._anisette_get_mid_ptr() as number;
|
||||
const midLen = this.m._anisette_get_mid_len() as number;
|
||||
|
||||
return {
|
||||
otp: this.readBytes(otpPtr, otpLen),
|
||||
machineId: this.readBytes(midPtr, midLen),
|
||||
};
|
||||
}
|
||||
}
|
||||
27
js/src/wasm-loader.ts
Normal file
27
js/src/wasm-loader.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Loads the Emscripten WASM glue bundled alongside this file.
|
||||
// The .wasm binary is resolved relative to this JS file at runtime.
|
||||
|
||||
// @ts-ignore — glue file is generated, no types available
|
||||
import ModuleFactory from "../../dist/anisette_rs.node.js";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
// Resolve the .wasm file next to the bundled output JS
|
||||
function resolveWasmPath(outputFile: string): string {
|
||||
// __filename of the *bundled* output — bun sets import.meta.url correctly
|
||||
const dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.join(dir, outputFile);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function loadWasm(): Promise<any> {
|
||||
return ModuleFactory({
|
||||
locateFile(file: string) {
|
||||
if (file.endsWith(".wasm")) {
|
||||
return resolveWasmPath("anisette_rs.node.wasm");
|
||||
}
|
||||
return file;
|
||||
},
|
||||
});
|
||||
}
|
||||
18
js/tsconfig.json
Normal file
18
js/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user