This commit is contained in:
libr
2026-03-03 10:12:22 +08:00
commit ae4c58e56d
223 changed files with 42635 additions and 0 deletions

View File

@@ -0,0 +1,573 @@
import { strFromU8, unzipSync } from "fflate"
import {
AppleAPI,
Fetch,
signIPA,
type AnisetteData,
type AppID,
type Certificate,
type Device,
type Team,
} from "altsign.js"
import { initLibcurl, libcurl } from "./anisette-libcurl-init"
const SIGNING_IDENTITY_STORAGE_KEY = "webmuxd:signing-identities"
const PRIMARY_APP_INFO_PLIST_RE = /^Payload\/[^/]+\.app\/Info\.plist$/
interface ParsedIpaInfo {
bundleId?: string
displayName?: string
}
interface CachedSigningIdentityPayload {
certId: string
certPublicKeyBase64: string
privateKeyBase64: string
}
interface StoredSigningIdentityMap {
[appleAndTeamKey: string]: CachedSigningIdentityPayload
}
export interface AppleSigningCredentials {
appleId: string
password: string
}
export interface AppleSigningRequest {
ipaFile: File
anisetteData: AnisetteData
credentials: AppleSigningCredentials
deviceUdid: string
deviceName?: string
bundleIdOverride?: string
displayNameOverride?: string
onLog: (message: string) => void
}
export interface AppleSigningResult {
signedFile: File
outputBundleId: string
teamId: string
}
let appleApiInstance: AppleAPI | null = null
function getAppleApi(): AppleAPI {
if (appleApiInstance) {
return appleApiInstance
}
const appleFetch = new Fetch(initLibcurl, async (url, options) => {
const response = await libcurl.fetch(url, {
method: options.method,
headers: options.headers,
body: options.body,
redirect: "manual",
insecure: true,
_libcurl_http_version: 1.1,
} as never)
return response
})
appleApiInstance = new AppleAPI(appleFetch)
return appleApiInstance
}
export async function signIpaWithApple(
request: AppleSigningRequest,
): Promise<AppleSigningResult> {
const { ipaFile, anisetteData, credentials, onLog } = request
const ipaData = new Uint8Array(await ipaFile.arrayBuffer())
const ipaInfo = readIpaInfo(ipaData)
const bundleIdBase = (request.bundleIdOverride ?? ipaInfo.bundleId ?? "").trim()
if (bundleIdBase.length === 0) {
throw new Error("Cannot sign IPA: bundle identifier is missing")
}
const appleId = credentials.appleId.trim()
const password = credentials.password
if (!appleId || !password) {
throw new Error("Cannot sign IPA: Apple ID or password is empty")
}
onLog(`Signing stage: authenticating Apple account ${maskEmail(appleId)}...`)
const api = getAppleApi()
const { session } = await api.authenticate(appleId, password, anisetteData, (submitCode) => {
const code = window.prompt("Apple 2FA code")
if (!code || code.trim().length === 0) {
throw new Error("2FA code is required")
}
submitCode(code.trim())
})
const team = await api.fetchTeam(session)
onLog(`Signing stage: using team ${team.identifier} (${team.name}).`)
const finalBundleId = buildTeamScopedBundleId(bundleIdBase, team.identifier)
const displayName = (request.displayNameOverride ?? ipaInfo.displayName ?? "").trim()
const identity = await ensureSigningIdentity(api, session, team, appleId, onLog)
await ensureDeviceRegistered(
api,
session,
team,
request.deviceUdid,
request.deviceName,
onLog,
)
const appId = await ensureAppId(api, session, team, finalBundleId, onLog)
onLog("Signing stage: fetching provisioning profile...")
const provisioningProfile = await api.fetchProvisioningProfile(session, team, appId)
onLog("Signing stage: resigning IPA in browser...")
const signed = await signIPA({
ipaData,
certificate: identity.certificate.publicKey,
privateKey: identity.privateKey,
provisioningProfile: provisioningProfile.data,
bundleID: finalBundleId,
displayName: displayName.length > 0 ? displayName : undefined,
adhoc: false,
forceSign: true,
})
const outputFileName = toSignedFileName(ipaFile.name)
const signedArray = new Uint8Array(signed.data.byteLength)
signedArray.set(signed.data)
const signedBuffer = signedArray.buffer.slice(0)
const signedFile = new File([signedBuffer], outputFileName, {
type: "application/octet-stream",
})
onLog(`Signing stage: complete (${signed.data.byteLength} bytes).`)
return {
signedFile,
outputBundleId: finalBundleId,
teamId: team.identifier,
}
}
async function ensureSigningIdentity(
api: AppleAPI,
session: { anisetteData: AnisetteData; dsid: string; authToken: string },
team: Team,
appleId: string,
onLog: (message: string) => void,
): Promise<{ certificate: Certificate; privateKey: Uint8Array }> {
const certificates = await api.fetchCertificates(session, team)
const cached = loadCachedSigningIdentity(appleId, team.identifier)
if (cached) {
const matched = certificates.find((item) => item.identifier === cached.certId)
if (matched) {
onLog(`Signing stage: using cached certificate ${matched.identifier}.`)
return {
certificate: {
...matched,
publicKey: base64ToBytes(cached.certPublicKeyBase64),
},
privateKey: base64ToBytes(cached.privateKeyBase64),
}
}
}
onLog("Signing stage: creating development certificate...")
let created: { certificate: Certificate; privateKey: Uint8Array }
try {
created = await api.addCertificate(session, team, `webmuxd-${Date.now()}`)
} catch (error) {
const message = String(error)
if (!message.includes("7460") || certificates.length === 0) {
throw error
}
const target = certificates[0]
onLog(`Signing stage: certificate limit hit, revoking ${target.identifier}...`)
await api.revokeCertificate(session, team, target)
created = await api.addCertificate(session, team, `webmuxd-${Date.now()}`)
}
saveCachedSigningIdentity(appleId, team.identifier, {
certId: created.certificate.identifier,
certPublicKeyBase64: bytesToBase64(created.certificate.publicKey),
privateKeyBase64: bytesToBase64(created.privateKey),
})
onLog(`Signing stage: certificate ready ${created.certificate.identifier}.`)
return created
}
async function ensureDeviceRegistered(
api: AppleAPI,
session: { anisetteData: AnisetteData; dsid: string; authToken: string },
team: Team,
deviceUdid: string,
deviceName: string | undefined,
onLog: (message: string) => void,
): Promise<void> {
const normalizedUdid = normalizeUdid(deviceUdid)
if (!normalizedUdid) {
onLog("Signing stage: skip device registration because UDID is empty.")
return
}
let devices: Device[] = []
try {
devices = await api.fetchDevices(session, team)
} catch (error) {
onLog(`Signing stage: fetchDevices failed, skip registration check: ${formatError(error)}`)
}
const existed = findRegisteredDevice(devices, normalizedUdid)
if (existed) {
onLog(`Signing stage: device already registered (${existed.identifier}).`)
return
}
const registerName =
deviceName && deviceName.trim().length > 0
? deviceName.trim()
: `webmuxd-${normalizedUdid.slice(-6)}`
try {
onLog(`Signing stage: registering device ${normalizedUdid} as ${registerName}...`)
await api.registerDevice(session, team, registerName, normalizedUdid)
onLog(`Signing stage: device registered (${normalizedUdid}).`)
} catch (error) {
onLog(`Signing stage: register failed, skip and continue: ${formatError(error)}`)
try {
const latestDevices = await api.fetchDevices(session, team)
const registered = findRegisteredDevice(latestDevices, normalizedUdid)
if (registered) {
onLog(`Signing stage: device confirmed in developer list (${registered.identifier}).`)
return
}
} catch (verifyError) {
onLog(`Signing stage: device verify after failure also failed: ${formatError(verifyError)}`)
}
onLog("Signing stage: continue without registration (may affect profile generation).")
return
}
try {
const latestDevices = await api.fetchDevices(session, team)
const registered = findRegisteredDevice(latestDevices, normalizedUdid)
if (registered) {
onLog(`Signing stage: device confirmed in developer list (${registered.identifier}).`)
}
} catch (error) {
onLog(`Signing stage: device verification skipped: ${formatError(error)}`)
}
}
async function ensureAppId(
api: AppleAPI,
session: { anisetteData: AnisetteData; dsid: string; authToken: string },
team: Team,
bundleId: string,
onLog: (message: string) => void,
): Promise<AppID> {
const appIds = await api.fetchAppIDs(session, team)
const matched = appIds.find((item) => item.bundleIdentifier === bundleId)
if (matched) {
onLog(`Signing stage: reuse App ID ${bundleId}.`)
return matched
}
onLog(`Signing stage: creating App ID ${bundleId}...`)
return api.addAppID(session, team, "WebMuxD Signed App", bundleId)
}
function readIpaInfo(ipaBytes: Uint8Array): ParsedIpaInfo {
const files = unzipSync(ipaBytes, {
filter: (file) => PRIMARY_APP_INFO_PLIST_RE.test(file.name),
})
const infoName = Object.keys(files).find((name) => PRIMARY_APP_INFO_PLIST_RE.test(name))
if (!infoName) {
return {}
}
const plistData = parseInfoPlist(files[infoName])
if (!plistData || typeof plistData !== "object" || Array.isArray(plistData)) {
return {}
}
const data = plistData as Record<string, unknown>
const bundleId =
typeof data.CFBundleIdentifier === "string" ? (data.CFBundleIdentifier as string) : undefined
const displayName =
typeof data.CFBundleDisplayName === "string"
? (data.CFBundleDisplayName as string)
: typeof data.CFBundleName === "string"
? (data.CFBundleName as string)
: undefined
return { bundleId, displayName }
}
function parseInfoPlist(infoPlistBytes: Uint8Array): unknown {
if (strFromU8(infoPlistBytes.subarray(0, 8)) === "bplist00") {
return parseBinaryPlist(infoPlistBytes)
}
const xml = strFromU8(infoPlistBytes)
return parseXmlPlist(xml)
}
function parseXmlPlist(xml: string): unknown {
const doc = new DOMParser().parseFromString(xml, "application/xml")
const parserError = doc.querySelector("parsererror")
if (parserError) {
return {}
}
const root = doc.querySelector("plist > *")
if (!root) {
return {}
}
return parseXmlNode(root)
}
function parseXmlNode(node: Element): unknown {
switch (node.tagName) {
case "dict": {
const map: Record<string, unknown> = {}
const children = Array.from(node.children)
for (let i = 0; i < children.length - 1; i += 2) {
const keyNode = children[i]
const valueNode = children[i + 1]
if (keyNode.tagName !== "key") {
continue
}
map[keyNode.textContent ?? ""] = parseXmlNode(valueNode)
}
return map
}
case "array":
return Array.from(node.children).map((child) => parseXmlNode(child))
case "string":
case "date":
return node.textContent ?? ""
case "integer":
return Number.parseInt(node.textContent ?? "0", 10)
case "real":
return Number.parseFloat(node.textContent ?? "0")
case "true":
return true
case "false":
return false
case "data":
return base64ToBytes((node.textContent ?? "").trim())
default:
return node.textContent ?? ""
}
}
function parseBinaryPlist(bytes: Uint8Array): unknown {
if (bytes.length < 40 || strFromU8(bytes.subarray(0, 8)) !== "bplist00") {
throw new Error("Invalid binary plist")
}
const trailerOffset = bytes.length - 32
const offsetSize = bytes[trailerOffset + 6]
const objectRefSize = bytes[trailerOffset + 7]
const objectCount = readUInt(bytes, trailerOffset + 8, 8)
const topObject = readUInt(bytes, trailerOffset + 16, 8)
const offsetTableStart = readUInt(bytes, trailerOffset + 24, 8)
const objectOffsets = new Array<number>(objectCount)
for (let i = 0; i < objectCount; i += 1) {
const entryOffset = offsetTableStart + i * offsetSize
objectOffsets[i] = readUInt(bytes, entryOffset, offsetSize)
}
const memo = new Map<number, unknown>()
const readLength = (offset: number, objectInfo: number): { length: number; nextOffset: number } => {
if (objectInfo < 0x0f) {
return { length: objectInfo, nextOffset: offset + 1 }
}
const marker = bytes[offset + 1]
const markerType = marker >> 4
const markerInfo = marker & 0x0f
if (markerType !== 0x1) {
throw new Error("Invalid binary plist length marker")
}
const intSize = 1 << markerInfo
const intOffset = offset + 2
return {
length: readUInt(bytes, intOffset, intSize),
nextOffset: intOffset + intSize,
}
}
const parseObject = (index: number): unknown => {
if (memo.has(index)) {
return memo.get(index)
}
const offset = objectOffsets[index]
const marker = bytes[offset]
const objectType = marker >> 4
const objectInfo = marker & 0x0f
let value: unknown
if (objectType === 0x0) {
value = objectInfo === 0x8 ? false : objectInfo === 0x9
} else if (objectType === 0x1) {
value = readUInt(bytes, offset + 1, 1 << objectInfo)
} else if (objectType === 0x2) {
const realSize = 1 << objectInfo
const view = new DataView(bytes.buffer, bytes.byteOffset + offset + 1, realSize)
value = realSize === 4 ? view.getFloat32(0, false) : view.getFloat64(0, false)
} else if (objectType === 0x5) {
const { length, nextOffset } = readLength(offset, objectInfo)
value = strFromU8(bytes.subarray(nextOffset, nextOffset + length))
} else if (objectType === 0x6) {
const { length, nextOffset } = readLength(offset, objectInfo)
value = decodeUtf16Be(bytes.subarray(nextOffset, nextOffset + length * 2))
} else if (objectType === 0xa) {
const { length, nextOffset } = readLength(offset, objectInfo)
const items: unknown[] = []
for (let i = 0; i < length; i += 1) {
const ref = readUInt(bytes, nextOffset + i * objectRefSize, objectRefSize)
items.push(parseObject(ref))
}
value = items
} else if (objectType === 0xd) {
const { length, nextOffset } = readLength(offset, objectInfo)
const map: Record<string, unknown> = {}
const valuesOffset = nextOffset + length * objectRefSize
for (let i = 0; i < length; i += 1) {
const keyRef = readUInt(bytes, nextOffset + i * objectRefSize, objectRefSize)
const valueRef = readUInt(bytes, valuesOffset + i * objectRefSize, objectRefSize)
const key = parseObject(keyRef)
if (typeof key === "string") {
map[key] = parseObject(valueRef)
}
}
value = map
} else {
value = null
}
memo.set(index, value)
return value
}
return parseObject(topObject)
}
function readUInt(bytes: Uint8Array, offset: number, length: number): number {
let value = 0
for (let i = 0; i < length; i += 1) {
value = value * 256 + bytes[offset + i]
}
return value
}
function decodeUtf16Be(bytes: Uint8Array): string {
let text = ""
for (let i = 0; i + 1 < bytes.length; i += 2) {
text += String.fromCharCode((bytes[i] << 8) | bytes[i + 1])
}
return text
}
function buildTeamScopedBundleId(baseBundleId: string, teamId: string): string {
const trimmedBase = baseBundleId.trim()
const trimmedTeam = teamId.trim()
if (!trimmedBase || !trimmedTeam) {
return trimmedBase
}
const lowerBase = trimmedBase.toLowerCase()
const lowerTeam = trimmedTeam.toLowerCase()
if (lowerBase.endsWith(`.${lowerTeam}`)) {
return trimmedBase
}
return `${trimmedBase}.${trimmedTeam}`
}
function toSignedFileName(name: string): string {
if (!name.toLowerCase().endsWith(".ipa")) {
return `${name}-signed.ipa`
}
return `${name.slice(0, -4)}-signed.ipa`
}
function loadCachedSigningIdentity(
appleId: string,
teamId: string,
): CachedSigningIdentityPayload | null {
const map = loadSigningIdentityMap()
const key = signingIdentityKey(appleId, teamId)
const value = map[key]
if (!value || !value.certId || !value.certPublicKeyBase64 || !value.privateKeyBase64) {
return null
}
return value
}
function saveCachedSigningIdentity(
appleId: string,
teamId: string,
payload: CachedSigningIdentityPayload,
): void {
const map = loadSigningIdentityMap()
map[signingIdentityKey(appleId, teamId)] = payload
window.localStorage.setItem(SIGNING_IDENTITY_STORAGE_KEY, JSON.stringify(map))
}
function loadSigningIdentityMap(): StoredSigningIdentityMap {
const raw = window.localStorage.getItem(SIGNING_IDENTITY_STORAGE_KEY)
if (!raw) {
return {}
}
try {
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {}
}
return parsed as StoredSigningIdentityMap
} catch {
return {}
}
}
function signingIdentityKey(appleId: string, teamId: string): string {
return `${appleId.trim().toLowerCase()}::${teamId.trim().toUpperCase()}`
}
function bytesToBase64(value: Uint8Array): string {
let binary = ""
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
function base64ToBytes(base64: string): Uint8Array {
const normalized = base64.replace(/\s+/g, "")
const binary = atob(normalized)
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i)
}
return out
}
function maskEmail(value: string): string {
const trimmed = value.trim()
const at = trimmed.indexOf("@")
if (at <= 1) {
return "***"
}
return `${trimmed.slice(0, 2)}***${trimmed.slice(at)}`
}
function findRegisteredDevice(devices: readonly Device[], normalizedUdid: string): Device | null {
return (
devices.find((item) => normalizeUdid(item.identifier) === normalizedUdid) ?? null
)
}
function normalizeUdid(value: string): string {
return value.trim().toUpperCase()
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error)
}