diff --git a/package-lock.json b/package-lock.json index f5dcb943..97b75a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5064,6 +5064,17 @@ "node": ">=4.0" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -27259,11 +27270,13 @@ "dependencies": { "@firebase/app-types": "^0.9.0", "@firebase/util": "^1.9.3", + "@noble/ciphers": "^1.3.0", "@peculiar/x509": "^1.6.1", "async-sema": "^3.1.1", "axios": "^1.7.2", "better-sqlite3": "^8.0.1", "blurhash": "^1.1.3", + "bplist-parser": "^0.3.2", "byte-base64": "^1.1.0", "compare-versions": "^3.6.0", "conditional-decorator": "^0.1.7", diff --git a/packages/server/package.json b/packages/server/package.json index 34f3ec18..d1125b8f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -84,11 +84,13 @@ "dependencies": { "@firebase/app-types": "^0.9.0", "@firebase/util": "^1.9.3", + "@noble/ciphers": "^1.3.0", "@peculiar/x509": "^1.6.1", "async-sema": "^3.1.1", "axios": "^1.7.2", "better-sqlite3": "^8.0.1", "blurhash": "^1.1.3", + "bplist-parser": "^0.3.2", "byte-base64": "^1.1.0", "compare-versions": "^3.6.0", "conditional-decorator": "^0.1.7", diff --git a/packages/server/src/server/api/interfaces/findMyInterface.ts b/packages/server/src/server/api/interfaces/findMyInterface.ts index 76349cad..9ccd37de 100644 --- a/packages/server/src/server/api/interfaces/findMyInterface.ts +++ b/packages/server/src/server/api/interfaces/findMyInterface.ts @@ -7,17 +7,41 @@ import { checkPrivateApiStatus, waitMs } from "@server/helpers/utils"; import { quitFindMyFriends, startFindMyFriends, showFindMyFriends, hideFindMyFriends } from "../apple/scripts"; import { FindMyDevice, FindMyItem, FindMyLocationItem } from "@server/api/lib/findmy/types"; import { transformFindMyItemToDevice } from "@server/api/lib/findmy/utils"; +import plist from "plist"; +import * as bplist from "bplist-parser"; +import os from "os"; export class FindMyInterface { + // 缓存密钥以避免重复读取 + private static fmipKey: Buffer | null = null; + private static fmfKey: Buffer | null = null; + + /** + * 解析 plist 文件(支持二进制和 XML 格式) + */ + private static async parsePlistFile(filePath: string): Promise { + const fileData = fs.readFileSync(filePath); + + // 检查是否是二进制 plist(以 "bplist" 开头) + if (fileData.toString('utf8', 0, 6) === 'bplist') { + Server().logger.debug(`Parsing binary plist: ${filePath}`); + const result = await bplist.parseBuffer(fileData); + return result[0]; // bplist-parser 返回数组,通常取第一个元素 + } else { + Server().logger.debug(`Parsing XML plist: ${filePath}`); + return plist.parse(fileData.toString('utf8')); + } + } + static async getFriends() { return Server().findMyCache.getAll(); } static async getDevices(): Promise | null> { - if (isMinSequoia) { - Server().logger.debug('Cannot fetch FindMy devices on macOS Sequoia or later.'); - return null; - } + // if (isMinSequoia) { + // Server().logger.debug('Cannot fetch FindMy devices on macOS Sequoia or later.'); + // return null; + // } try { const [devices, items] = await Promise.all([ @@ -124,6 +148,7 @@ export class FindMyInterface { if (Array.isArray(parsedData)) { return resolve(parsedData); } else { + Server().logger.debug(data.toString()); reject(new Error("Failed to read FindMy ItemGroups cache file! It is not an array!")); } } catch { @@ -133,26 +158,251 @@ export class FindMyInterface { }); } - private static readDataFile( - type: T - ): Promise | null> { - const devicesPath = path.join(FileSystem.findMyDir, `${type}.data`); - return new Promise((resolve, reject) => { - fs.readFile(devicesPath, { encoding: "utf-8" }, (err, data) => { - // Couldn't read the file - if (err) return resolve(null); + /** + * 加载 FMIP 组的解密密钥 + */ + private static async loadFMIPKey(): Promise { + if (this.fmipKey) return this.fmipKey; - try { - const parsedData = JSON.parse(data.toString()); - if (Array.isArray(parsedData)) { - return resolve(parsedData); + try { + const keyPath = path.join(os.homedir(), "FMIPDataManager.bplist"); + if (!fs.existsSync(keyPath)) { + Server().logger.debug(`FMIP key file not found: ${keyPath}`); + return null; + } + + const plistData = await this.parsePlistFile(keyPath); + + const symmetricKeyData = plistData.symmetricKey; + if (!symmetricKeyData) { + Server().logger.debug("Missing symmetricKey in FMIP plist"); + return null; + } + + let symmetricKeyBytes: Buffer; + if (typeof symmetricKeyData === 'object' && symmetricKeyData.key) { + // 嵌套格式: symmetricKey -> key -> data + const keyDict = symmetricKeyData.key; + if (keyDict.data) { + if (Buffer.isBuffer(keyDict.data)) { + symmetricKeyBytes = keyDict.data; } else { - reject(new Error(`Failed to read FindMy ${type} cache file! It is not an array!`)); + symmetricKeyBytes = Buffer.from(keyDict.data, 'base64'); } + } else { + Server().logger.debug("Invalid symmetricKey structure in FMIP plist"); + return null; + } + } else { + // 直接格式: 直接是 base64 字符串 + symmetricKeyBytes = Buffer.from(symmetricKeyData, 'base64'); + } + + if (symmetricKeyBytes.length !== 32) { + Server().logger.debug(`Invalid FMIP key length: ${symmetricKeyBytes.length} bytes, expected 32`); + return null; + } + + this.fmipKey = symmetricKeyBytes; + Server().logger.debug("FMIP decryption key loaded successfully"); + return this.fmipKey; + } catch (ex: any) { + Server().logger.debug(`Failed to load FMIP key: ${String(ex)}`); + return null; + } + } + + /** + * 加载 FMF 组的解密密钥 + */ + private static async loadFMFKey(): Promise { + if (this.fmfKey) return this.fmfKey; + + try { + const keyPath = path.join(os.homedir(), "FMFDataManager.bplist"); + if (!fs.existsSync(keyPath)) { + Server().logger.debug(`FMF key file not found: ${keyPath}`); + return null; + } + + const plistData = await this.parsePlistFile(keyPath); + + const symmetricKeyData = plistData.symmetricKey; + if (!symmetricKeyData) { + Server().logger.debug("Missing symmetricKey in FMF plist"); + return null; + } + + let symmetricKeyBytes: Buffer; + if (typeof symmetricKeyData === 'object' && symmetricKeyData.key) { + // 嵌套格式: symmetricKey -> key -> data + const keyDict = symmetricKeyData.key; + if (keyDict.data) { + if (Buffer.isBuffer(keyDict.data)) { + symmetricKeyBytes = keyDict.data; + } else { + symmetricKeyBytes = Buffer.from(keyDict.data, 'base64'); + } + } else { + Server().logger.debug("Invalid symmetricKey structure in FMF plist"); + return null; + } + } else { + // 直接格式: 直接是 base64 字符串 + symmetricKeyBytes = Buffer.from(symmetricKeyData, 'base64'); + } + + if (symmetricKeyBytes.length !== 32) { + Server().logger.debug(`Invalid FMF key length: ${symmetricKeyBytes.length} bytes, expected 32`); + return null; + } + + this.fmfKey = symmetricKeyBytes; + Server().logger.debug("FMF decryption key loaded successfully"); + return this.fmfKey; + } catch (ex: any) { + Server().logger.debug(`Failed to load FMF key: ${String(ex)}`); + return null; + } + } + + /** + * 使用 ChaCha20-Poly1305 解密数据 + */ + private static async decryptChaCha20Poly1305(encryptedData: Buffer, key: Buffer): Promise { + try { + // 动态导入 @noble/ciphers 以避免编译时错误 + const { chacha20poly1305 } = await import('@noble/ciphers/chacha'); + + if (encryptedData.length < 28) { + Server().logger.debug("Encrypted data too short for ChaCha20-Poly1305"); + return null; + } + + // ChaCha20-Poly1305 结构: 12字节nonce + 密文 + 16字节认证标签 + const nonce = encryptedData.subarray(0, 12); + const ciphertextWithTag = encryptedData.subarray(12); + + // 创建解密器 - 转换为 Uint8Array + const cipher = chacha20poly1305(new Uint8Array(key), new Uint8Array(nonce)); + + // 解密数据 - 转换为 Uint8Array + const decrypted = cipher.decrypt(new Uint8Array(ciphertextWithTag)); + + return Buffer.from(decrypted); + } catch (ex: any) { + Server().logger.debug(`ChaCha20-Poly1305 decryption failed: ${String(ex)}`); + return null; + } + } + + /** + * 解密缓存文件 + */ + private static async decryptCacheFile(filePath: string, keyType: 'FMIP' | 'FMF'): Promise { + try { + if (!fs.existsSync(filePath)) { + return null; + } + + // 读取并解析 plist 文件 + const plistData = await this.parsePlistFile(filePath); + + // 提取加密数据 + const encryptedData = plistData.encryptedData; + if (!encryptedData) { + Server().logger.debug(`Missing encryptedData in ${filePath}`); + return null; + } + + // 转换为 Buffer + const encryptedBuffer = Buffer.isBuffer(encryptedData) + ? encryptedData + : Buffer.from(encryptedData); + + // 加载对应的密钥 + const key = keyType === 'FMIP' + ? await this.loadFMIPKey() + : await this.loadFMFKey(); + + if (!key) { + Server().logger.debug(`${keyType} decryption key not available`); + return null; + } + + // 解密数据 + const decrypted = await this.decryptChaCha20Poly1305(encryptedBuffer, key); + if (!decrypted) { + Server().logger.debug(`Failed to decrypt ${filePath}`); + return null; + } + + // 解析解密后的数据 + let parsedData: any; + if (decrypted.toString().startsWith('bplist')) { + // 如果是 bplist 格式 + const result = await bplist.parseBuffer(decrypted); + parsedData = result[0]; + } else { + // 尝试解析为 JSON + try { + parsedData = JSON.parse(decrypted.toString()); } catch { - reject(new Error(`Failed to read FindMy ${type} cache file! It is not in the correct format!`)); + Server().logger.debug(`Failed to parse decrypted data from ${filePath} as JSON`); + return null; } - }); + } + + // 返回数组数据 + if (Array.isArray(parsedData)) { + return parsedData; + } else { + Server().logger.debug(`Decrypted data from ${filePath} is not an array`); + return null; + } + } catch (ex: any) { + Server().logger.debug(`Failed to decrypt cache file ${filePath}: ${String(ex)}`); + return null; + } + } + + private static readDataFile( + type: T + ): Promise | null> { + const dataPath = path.join(FileSystem.findMyDir, `${type}.data`); + + return new Promise((resolve, reject) => { + // 首先尝试解密方式读取 + this.decryptCacheFile(dataPath, 'FMIP') + .then(decryptedData => { + if (decryptedData) { + Server().logger.debug(`Successfully decrypted ${type} data`); + return resolve(decryptedData); + } + + // 如果解密失败,尝试传统方式读取(向后兼容) + fs.readFile(dataPath, { encoding: "utf-8" }, (err, data) => { + // Couldn't read the file + if (err) return resolve(null); + + try { + const parsedData = JSON.parse(data.toString()); + if (Array.isArray(parsedData)) { + return resolve(parsedData); + } else { + reject(new Error(`Failed to read FindMy ${type} cache file! It is not an array!`)); + } + } catch { + reject(new Error( + `Failed to read FindMy ${type} cache file! It is not in the correct format!` + )); + } + }); + }) + .catch((ex: any) => { + Server().logger.debug(`Error reading ${type} data file: ${String(ex)}`); + return resolve(null); + }); }); } }