mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
Add Registry20 GUID management and DPAPI support
Introduce tools and UI to read, write, backup and restore QQ Registry20 GUIDs using Windows DPAPI. Adds a Python CLI (guid_tool.py) for local Registry20 operations and a new TypeScript package napcat-dpapi with native bindings for DPAPI. Implements Registry20Utils in the webui-backend to protect/unprotect Registry20, plus backup/restore/delete helpers. Exposes new backend API handlers and routes (get/set GUID, backups, restore, reset, restart) and integrates frontend GUIDManager component and qq_manager controller methods. Propagates QQ data path via WebUiDataRuntime (setter/getter) and wires it up in framework/shell; updates Vite alias and package.json to include the new dpapi workspace. Includes native addon binaries for win32 x64/arm64 and basic tsconfig/readme/license for the new package.
This commit is contained in:
@@ -4,6 +4,22 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { Registry20Utils } from '@/napcat-webui-backend/src/utils/guid';
|
||||
|
||||
// 获取 Registry20 路径的辅助函数
|
||||
const getRegistryPath = () => {
|
||||
// 优先从 WebUiDataRuntime 获取早期设置的 dataPath
|
||||
let dataPath = WebUiDataRuntime.getQQDataPath();
|
||||
if (!dataPath) {
|
||||
// 回退: 从 OneBotContext 获取
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
dataPath = oneBotContext?.core?.dataPath;
|
||||
}
|
||||
if (!dataPath) {
|
||||
throw new Error('QQ data path not available yet');
|
||||
}
|
||||
return Registry20Utils.getRegistryPath(dataPath);
|
||||
};
|
||||
|
||||
// 获取QQ登录二维码
|
||||
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
@@ -147,3 +163,106 @@ export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 重置设备信息
|
||||
export const QQResetDeviceIDHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
// 自动备份
|
||||
try {
|
||||
await Registry20Utils.backup(registryPath);
|
||||
} catch (e) {
|
||||
// 忽略备份错误(例如文件不存在)
|
||||
}
|
||||
|
||||
await Registry20Utils.delete(registryPath);
|
||||
return sendSuccess(res, { message: 'Device ID reset successfully (Registry20 deleted)' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to reset Device ID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取设备 GUID
|
||||
export const QQGetDeviceGUIDHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
const guid = await Registry20Utils.readGuid(registryPath);
|
||||
return sendSuccess(res, { guid });
|
||||
} catch (e) {
|
||||
// 可能是文件不存在,或者非 Windows 平台,或者解密失败
|
||||
return sendError(res, `Failed to get GUID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置设备 GUID
|
||||
export const QQSetDeviceGUIDHandler: RequestHandler = async (req, res) => {
|
||||
const { guid } = req.body;
|
||||
if (!guid || typeof guid !== 'string' || guid.length !== 32) {
|
||||
return sendError(res, 'Invalid GUID format, must be 32 hex characters');
|
||||
}
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
// 自动备份
|
||||
try {
|
||||
await Registry20Utils.backup(registryPath);
|
||||
} catch { }
|
||||
|
||||
await Registry20Utils.writeGuid(registryPath, guid);
|
||||
return sendSuccess(res, { message: 'GUID set successfully' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to set GUID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取备份列表
|
||||
export const QQGetGUIDBackupsHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
const backups = Registry20Utils.getBackups(registryPath);
|
||||
return sendSuccess(res, backups);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to get backups: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复备份
|
||||
export const QQRestoreGUIDBackupHandler: RequestHandler = async (req, res) => {
|
||||
const { backupName } = req.body;
|
||||
if (!backupName) {
|
||||
return sendError(res, 'Backup name is required');
|
||||
}
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
await Registry20Utils.restore(registryPath, backupName);
|
||||
return sendSuccess(res, { message: 'Restored successfully' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to restore: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建备份
|
||||
export const QQCreateGUIDBackupHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
const backupPath = await Registry20Utils.backup(registryPath);
|
||||
return sendSuccess(res, { message: 'Backup created', path: backupPath });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to backup: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 重启NapCat
|
||||
export const QQRestartNapCatHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const result = await WebUiDataRuntime.requestRestartProcess();
|
||||
if (result.result) {
|
||||
return sendSuccess(res, { message: result.message || 'Restart initiated' });
|
||||
} else {
|
||||
return sendError(res, result.message || 'Restart failed');
|
||||
}
|
||||
} catch (e) {
|
||||
return sendError(res, `Restart error: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
},
|
||||
QQLoginError: '',
|
||||
QQVersion: 'unknown',
|
||||
QQDataPath: '',
|
||||
OneBotContext: null,
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
@@ -167,6 +168,14 @@ export const WebUiDataRuntime = {
|
||||
return LoginRuntime.QQVersion;
|
||||
},
|
||||
|
||||
setQQDataPath (dataPath: string) {
|
||||
LoginRuntime.QQDataPath = dataPath;
|
||||
},
|
||||
|
||||
getQQDataPath (): string {
|
||||
return LoginRuntime.QQDataPath;
|
||||
},
|
||||
|
||||
setWebUiConfigQuickFunction (func: LoginRuntimeType['WebUiConfigQuickFunction']): void {
|
||||
LoginRuntime.WebUiConfigQuickFunction = func;
|
||||
},
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
setAutoLoginAccountHandler,
|
||||
QQRefreshQRcodeHandler,
|
||||
QQPasswordLoginHandler,
|
||||
QQResetDeviceIDHandler,
|
||||
QQRestartNapCatHandler,
|
||||
QQGetDeviceGUIDHandler,
|
||||
QQSetDeviceGUIDHandler,
|
||||
QQGetGUIDBackupsHandler,
|
||||
QQRestoreGUIDBackupHandler,
|
||||
QQCreateGUIDBackupHandler,
|
||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||
|
||||
const router: Router = Router();
|
||||
@@ -34,5 +41,19 @@ router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||
// router:密码登录
|
||||
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
||||
// router:重置设备信息
|
||||
router.post('/ResetDeviceID', QQResetDeviceIDHandler);
|
||||
// router:重启NapCat
|
||||
router.post('/RestartNapCat', QQRestartNapCatHandler);
|
||||
// router:获取设备GUID
|
||||
router.post('/GetDeviceGUID', QQGetDeviceGUIDHandler);
|
||||
// router:设置设备GUID
|
||||
router.post('/SetDeviceGUID', QQSetDeviceGUIDHandler);
|
||||
// router:获取GUID备份列表
|
||||
router.post('/GetGUIDBackups', QQGetGUIDBackupsHandler);
|
||||
// router:恢复GUID备份
|
||||
router.post('/RestoreGUIDBackup', QQRestoreGUIDBackupHandler);
|
||||
// router:创建GUID备份
|
||||
router.post('/CreateGUIDBackup', QQCreateGUIDBackupHandler);
|
||||
|
||||
export { router as QQLoginRouter };
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface LoginRuntimeType {
|
||||
QQLoginInfo: SelfInfo;
|
||||
QQLoginError: string;
|
||||
QQVersion: string;
|
||||
QQDataPath: string;
|
||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
onRefreshQRCode: () => Promise<void>;
|
||||
|
||||
116
packages/napcat-webui-backend/src/utils/guid.ts
Normal file
116
packages/napcat-webui-backend/src/utils/guid.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { protectData, unprotectData } from 'napcat-dpapi';
|
||||
|
||||
const GUID_HEADER = Buffer.from([0x00, 0x00, 0x00, 0x14]);
|
||||
const XOR_KEY = 0x10;
|
||||
|
||||
/**
|
||||
* Unprotects data using Windows DPAPI via napcat-dpapi.
|
||||
*/
|
||||
function dpapiUnprotect (filePath: string): Buffer {
|
||||
const encrypted = fs.readFileSync(filePath);
|
||||
return Buffer.from(unprotectData(encrypted, null, 'CurrentUser'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Protects data using Windows DPAPI and writes to file.
|
||||
*/
|
||||
function dpapiProtectAndWrite (filePath: string, data: Buffer): void {
|
||||
const encrypted = protectData(data, null, 'CurrentUser');
|
||||
fs.writeFileSync(filePath, Buffer.from(encrypted));
|
||||
}
|
||||
|
||||
export class Registry20Utils {
|
||||
static getRegistryPath (dataPath: string): string {
|
||||
return path.join(dataPath, 'nt_qq', 'global', 'nt_data', 'msf', 'Registry20');
|
||||
}
|
||||
|
||||
static readGuid (registryPath: string): string {
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
throw new Error('Registry20 file not found');
|
||||
}
|
||||
if (os.platform() !== 'win32') {
|
||||
throw new Error('Registry20 decryption is only supported on Windows');
|
||||
}
|
||||
|
||||
const decrypted = dpapiUnprotect(registryPath);
|
||||
|
||||
if (decrypted.length < 20) {
|
||||
throw new Error(`Decrypted data too short (got ${decrypted.length} bytes, need 20)`);
|
||||
}
|
||||
|
||||
// Decode payload: header(4) + obfuscated_guid(16)
|
||||
const payload = decrypted.subarray(4, 20);
|
||||
const guidBuf = Buffer.alloc(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const payloadByte = payload[i] ?? 0;
|
||||
guidBuf[i] = (~(payloadByte ^ XOR_KEY)) & 0xFF;
|
||||
}
|
||||
|
||||
return guidBuf.toString('hex');
|
||||
}
|
||||
|
||||
static writeGuid (registryPath: string, guidHex: string): void {
|
||||
if (guidHex.length !== 32) {
|
||||
throw new Error('Invalid GUID length, must be 32 hex chars');
|
||||
}
|
||||
if (os.platform() !== 'win32') {
|
||||
throw new Error('Registry20 encryption is only supported on Windows');
|
||||
}
|
||||
|
||||
const guidBytes = Buffer.from(guidHex, 'hex');
|
||||
const payload = Buffer.alloc(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const guidByte = guidBytes[i] ?? 0;
|
||||
payload[i] = XOR_KEY ^ (~guidByte & 0xFF);
|
||||
}
|
||||
|
||||
const data = Buffer.concat([GUID_HEADER, payload]);
|
||||
|
||||
// Create directory if not exists
|
||||
const dir = path.dirname(registryPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
dpapiProtectAndWrite(registryPath, data);
|
||||
}
|
||||
|
||||
static getBackups (registryPath: string): string[] {
|
||||
const dir = path.dirname(registryPath);
|
||||
const baseName = path.basename(registryPath);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
return fs.readdirSync(dir)
|
||||
.filter(f => f.startsWith(`${baseName}.bak.`))
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
static backup (registryPath: string): string {
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
throw new Error('Registry20 does not exist');
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14);
|
||||
const backupPath = `${registryPath}.bak.${timestamp}`;
|
||||
fs.copyFileSync(registryPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
static restore (registryPath: string, backupFileName: string): void {
|
||||
const dir = path.dirname(registryPath);
|
||||
const backupPath = path.join(dir, backupFileName);
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error('Backup file not found');
|
||||
}
|
||||
fs.copyFileSync(backupPath, registryPath);
|
||||
}
|
||||
|
||||
static delete (registryPath: string): void {
|
||||
if (fs.existsSync(registryPath)) {
|
||||
fs.unlinkSync(registryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user