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:
2026-02-28 00:36:15 +08:00
parent 80038ce8f2
commit d05cc41660
22 changed files with 4520 additions and 19 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,7 @@
target/
test/
dist/
cache/
*.apk
anisette/*
js/node_modules/*

1916
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

138
README.md
View File

@@ -1,18 +1,134 @@
# anisette.js
# anisette-js
use anisette on browser locally! no more third-party server worries.
Apple Anisette authentication in browser via WebAssembly. Emulates ARM64 Android binaries to generate Anisette headers locally no third-party servers required.
## usage
## Features
see examples/
- **Local execution**: All computation happens in your browser or Node.js process
- **WASM-based**: Uses Unicorn Engine compiled to WebAssembly for ARM64 emulation
- **High-level JS/TS API**: Simple async interface, handles provisioning automatically
- **Single-file bundle**: Distribute as one `.js` + one `.wasm` file
should download apple music apk from [here](https://apps.mzstatic.com/content/android-apple-music-apk/applemusic.apk) and unzip to get arm64 abi.
## Prerequisites
- Rust nightly (for building the WASM module)
- Emscripten SDK
- Bun (for bundling the TypeScript API)
Android library blobs (`libstoreservicescore.so`, `libCoreADI.so`) are not included. Extract them from an Apple Music APK or obtain separately.
## Build
```bash
# Clone and build custom Unicorn fork
git clone https://github.com/lbr77/unicorn.git
cd unicorn && git checkout tci-emscripten
# Build everything (WASM + TS API bundle)
bash script/build-glue.sh
# Or build just the JS bundle (WASM already built)
npm run build:js
```
Output files in `dist/`:
- `anisette.js` — bundled TS API + glue (single file)
- `anisette_rs.node.wasm` — WASM binary (required alongside `.js`)
## Usage
### Node.js
```javascript
import { Anisette, loadWasm } from "./dist/anisette.js";
import fs from "node:fs/promises";
const wasmModule = await loadWasm();
const storeservices = new Uint8Array(await fs.readFile("libstoreservicescore.so"));
const coreadi = new Uint8Array(await fs.readFile("libCoreADI.so"));
const anisette = await Anisette.fromSo(storeservices, coreadi, wasmModule);
if (!anisette.isProvisioned) {
await anisette.provision();
}
const headers = await anisette.getData();
console.log(headers["X-Apple-I-MD"]);
```
Run the example:
```bash
node example/anisette-api.mjs libstoreservicescore.so libCoreADI.so ./anisette/
```
### Browser
For browser usage, use the web-targeted WASM build (`anisette_rs.js` / `.wasm`) and import directly:
```javascript
import ModuleFactory from "./anisette_rs.js";
const wasmModule = await ModuleFactory({
locateFile: (f) => f.endsWith(".wasm") ? "./anisette_rs.wasm" : f
});
// Use WasmBridge for low-level access, or wrap with the TS API
```
## API Reference
### `Anisette`
Main class for generating Anisette headers.
**Static methods:**
- `Anisette.fromSo(storeservicescore, coreadi, wasmModule, options?)` — Initialize from library blobs
- `Anisette.fromSaved(ss, ca, deviceJson, adiPb, wasmModule, options?)` — Restore a saved session
**Instance properties:**
- `isProvisioned: boolean` — Whether the device is provisioned
**Instance methods:**
- `provision()` — Run Apple provisioning flow
- `getData(): Promise<AnisetteHeaders>` — Generate Anisette headers
- `getDeviceJson(): Uint8Array` — Serialize device config for persistence
### `loadWasm()`
Loads the WASM module. In Node.js, resolves `.wasm` path relative to the bundle location.
```javascript
import { loadWasm } from "./dist/anisette.js";
const wasmModule = await loadWasm();
```
## Architecture
- **Rust/WASM core** (`src/`): Emulator, ADI wrapper, provisioning protocol
- **TypeScript API** (`js/src/`): High-level wrapper around WASM exports
- **Emscripten glue**: Bridges JS and WASM memory, handles VFS
Key modules:
- `adi.rs` — ADI (Apple Device Identity) provisioning and OTP
- `emu.rs` — Unicorn-based ARM64 emulator
- `exports.rs` — C FFI exports for WASM
- `js/src/anisette.ts` — Main `Anisette` class
- `js/src/wasm-bridge.ts` — Low-level WASM memory management
## Credits
- [pyprovision-uc](https://github.com/JayFoxRox/pyprovision-uc)
- [Anisette.py](https://github.com/malmeloo/Anisette.py)
- [omnisette-server](https://github.com/SideStore/omnisette-server)
- [unicorn](https://github.com/petabyt/unicorn/tree/tci-emscripten)
## credits
## Known Issue:
[pyprovision-uc](https://github.com/JayFoxRox/pyprovision-uc)
[Anisette.py](https://github.com/malmeloo/Anisette.py)
[omnisette-server](https://github.com/SideStore/omnisette-server)
when requiring Otp for second time there will be a "WRITE UNMAPPED" error which could be avoided by initalizing onemoretime...

52
example/anisette-api.mjs Normal file
View File

@@ -0,0 +1,52 @@
/**
* Example: using the high-level Anisette JS API (Node.js)
*
* Usage:
* node example/anisette-api.mjs <libstoreservicescore.so> <libCoreADI.so> [library_path]
*/
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const bundlePath = path.join(__dirname, "..", "dist", "anisette.js");
const { Anisette, loadWasm } = await import(
pathToFileURL(bundlePath).href
).catch(() => {
console.error("Bundle not found. Run: npm run build:js");
process.exit(1);
});
const args = process.argv.slice(2);
if (args.length < 2) {
console.error(
"usage: node example/anisette-api.mjs <libstoreservicescore.so> <libCoreADI.so> [library_path]"
);
process.exit(1);
}
const storeservicesPath = args[0];
const coreadiPath = args[1];
const libraryPath = args[2] ?? "./anisette/";
const wasmModule = await loadWasm();
const storeservices = new Uint8Array(await fs.readFile(storeservicesPath));
const coreadi = new Uint8Array(await fs.readFile(coreadiPath));
const anisette = await Anisette.fromSo(storeservices, coreadi, wasmModule, {
init: { libraryPath },
});
if (!anisette.isProvisioned) {
console.log("Device not provisioned — running provisioning...");
await anisette.provision();
console.log("Provisioning complete.");
} else {
console.log("Device already provisioned.");
}
const headers = await anisette.getData();
console.log(JSON.stringify(headers, null, 2));

1454
example/index.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.join(__dirname, 'dist');
const distDir = path.join(path.join(__dirname, '..'), 'dist')
const modulePath = path.join(distDir, 'anisette_rs.node.js');
const wasmPath = path.join(distDir, 'anisette_rs.node.wasm');

15
js/bun.lock Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}

View File

@@ -4,6 +4,9 @@
"build:unicorn": "bash script/rebuild-unicorn.sh",
"build:glue": "bash script/build-glue.sh",
"build:release": "bash script/build-glue.sh --release",
"run:node": "bun test/run-node.mjs"
"build:js": "cd js && bun install && bun run build",
"build": "bash script/build-glue.sh && cd js && bun install && bun run build",
"run:node": "node example/run-node.mjs",
"run:api": "node example/anisette-api.mjs"
}
}

View File

@@ -95,6 +95,18 @@ echo " ${DIST_DIR}/anisette_rs.wasm"
echo " ${NODE_DIST_JS}"
echo " ${NODE_DIST_WASM}"
# Bundle TS API + glue into a single JS file
JS_DIR="${ROOT_DIR}/js"
if command -v bun >/dev/null 2>&1 && [[ -f "${JS_DIR}/src/index.ts" ]]; then
echo "bundling TS API..."
bun build "${JS_DIR}/src/index.ts" \
--outfile "${DIST_DIR}/anisette.js" \
--target node \
--format esm \
--minify
echo " ${DIST_DIR}/anisette.js"
fi
# Copy to frontend if directory exists (skip in CI if not present)
if [[ -d "${ROOT_DIR}/../../frontend/public/anisette" ]]; then
cp "${DIST_DIR}/anisette_rs.js" "${ROOT_DIR}/../../frontend/public/anisette/anisette_rs.js"

View File

@@ -33,25 +33,25 @@ pub(crate) fn trace_mem_invalid_hook(
let pc = reg_or_zero(uc, RegisterARM64::PC);
match access {
MemType::READ_UNMAPPED => {
debug_print(format!(
println!(
">>> Missing memory is being READ at 0x{address:x}, data size = {size}, data value = 0x{:x}, PC=0x{pc:x}",
value as u64
));
);
dump_registers(uc, "read unmapped");
}
MemType::WRITE_UNMAPPED => {
debug_print(format!(
println!(
">>> Missing memory is being WRITE at 0x{address:x}, data size = {size}, data value = 0x{:x}, PC=0x{pc:x}",
value as u64
));
);
dump_registers(uc, "write unmapped");
}
MemType::FETCH_UNMAPPED => {
debug_print(format!(
println!(
">>> Missing memory is being FETCH at 0x{address:x}, data size = {size}, data value = 0x{:x}, PC=0x{pc:x}",
value as u64
));
);
}
_ => {}
}