Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
288 changes: 269 additions & 19 deletions packages/server/src/server/api/interfaces/findMyInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
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<Array<FindMyDevice> | 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([
Expand Down Expand Up @@ -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 {
Expand All @@ -133,26 +158,251 @@ export class FindMyInterface {
});
}

private static readDataFile<T extends "Devices" | "Items">(
type: T
): Promise<Array<T extends "Devices" ? FindMyDevice : FindMyItem> | 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<Buffer | null> {
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<Buffer | null> {
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<Buffer | null> {
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<any[] | null> {
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<T extends "Devices" | "Items">(
type: T
): Promise<Array<T extends "Devices" ? FindMyDevice : FindMyItem> | 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);
});
});
}
}