NapCatQQ source snapshot
Build NapCat Artifacts / Type Check & Test (push) Waiting to run
Release Tag / validate-version (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:01:47 +08:00
commit f954252a34
1111 files changed
+139867

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
{
"name": "napcat-database",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./src/index.ts"
},
"./src/*": {
"import": "./src/*"
}
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}
+35
View File
@@ -0,0 +1,35 @@
/** 文件头部扩展区大小 */
export const EXT_HEADER_SIZE = 1024;
/** SQLCipher 页大小 */
export const PAGE_SIZE = 4096;
/** PBKDF2 salt 长度 */
export const SALT_SIZE = 16;
/** AES-256 密钥长度 */
export const KEY_SIZE = 32;
/** AES-CBC IV 长度 */
export const IV_SIZE = 16;
/** HMAC-SHA1 长度 */
export const HMAC_SIZE = 20;
/** 每页保留区大小: IV(16) + HMAC_SHA1(20) + pad(12) = 48 */
export const RESERVE_SIZE = 48;
/** PBKDF2 主密钥迭代次数 */
export const KDF_ITERATIONS = 4000;
/** PBKDF2 HMAC 密钥迭代次数 */
export const FAST_ITERATIONS = 2;
/** HMAC salt XOR 掩码 */
export const HMAC_MASK = 0x3a;
/** SQLite 加密文件头 */
export const SQLITE_HEADER = Buffer.from('SQLite header 3\0');
/** SQLite 明文文件头 */
export const SQLITE_FORMAT = Buffer.from('SQLite format 3\0');
+29
View File
@@ -0,0 +1,29 @@
/**
* 密钥派生
* enc_key = PBKDF2_HMAC_SHA512(passphrase, salt, 4000, 32)
* hmac_key = PBKDF2_HMAC_SHA512(enc_key, salt ^ 0x3a, 2, 32)
*/
import { pbkdf2Sync } from 'node:crypto';
import {
KEY_SIZE,
KDF_ITERATIONS,
FAST_ITERATIONS,
HMAC_MASK,
} from './constants';
export interface DerivedKeys {
encKey: Buffer;
hmacKey: Buffer;
}
export function deriveKeys (passphrase: Buffer, salt: Buffer): DerivedKeys {
const encKey = pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, KEY_SIZE, 'sha512');
const hmacSalt = Buffer.alloc(salt.length);
for (let i = 0; i < salt.length; i++) {
hmacSalt[i] = salt[i]! ^ HMAC_MASK;
}
const hmacKey = pbkdf2Sync(encKey, hmacSalt, FAST_ITERATIONS, KEY_SIZE, 'sha512');
return { encKey, hmacKey };
}
+109
View File
@@ -0,0 +1,109 @@
import { createDecipheriv } from 'node:crypto';
import fs from 'node:fs';
import {
EXT_HEADER_SIZE,
PAGE_SIZE,
SALT_SIZE,
IV_SIZE,
RESERVE_SIZE,
SQLITE_HEADER,
SQLITE_FORMAT,
} from './constants';
import { deriveKeys } from './crypto';
/**
* 解密单个 SQLCipher 页面
* @param pageData 原始页数据 (4096 字节)
* @param encKey AES-256 密钥
* @param skipSalt 页 1 需跳过前 16 字节 salt
*/
function decryptPage (pageData: Buffer, encKey: Buffer, skipSalt: number = 0): Buffer {
const data = pageData.subarray(skipSalt);
const contentLen = data.length - RESERVE_SIZE;
const encrypted = data.subarray(0, contentLen);
const iv = data.subarray(contentLen, contentLen + IV_SIZE);
const decipher = createDecipheriv('aes-256-cbc', encKey, iv);
decipher.setAutoPadding(false);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
/**
* 检查文件是否为 NTQQ 加密数据库
*/
export function isEncryptedNTDB (fileData: Buffer): boolean {
if (fileData.length < EXT_HEADER_SIZE + PAGE_SIZE) return false;
return fileData.subarray(0, 16).equals(SQLITE_HEADER);
}
/**
* 将加密的 NTQQ 数据库解密为标准 SQLite 数据库
* @param fileData 完整的加密数据库文件内容
* @param passphrase 服务端返回的密钥口令
* @returns 解密后的 SQLite 数据库 Buffer,失败返回 null
*/
export function decryptDatabase (fileData: Buffer, passphrase: Buffer): Buffer | null {
if (!isEncryptedNTDB(fileData)) return null;
const scData = fileData.subarray(EXT_HEADER_SIZE);
const totalPages = Math.floor(scData.length / PAGE_SIZE);
if (totalPages === 0) return null;
const salt = scData.subarray(0, SALT_SIZE);
const { encKey } = deriveKeys(passphrase, salt);
const output = Buffer.alloc(totalPages * PAGE_SIZE);
let offset = 0;
for (let pgno = 1; pgno <= totalPages; pgno++) {
const pageOffset = (pgno - 1) * PAGE_SIZE;
const rawPage = scData.subarray(pageOffset, pageOffset + PAGE_SIZE);
const skip = pgno === 1 ? SALT_SIZE : 0;
const decrypted = decryptPage(rawPage, encKey, skip);
if (pgno === 1) {
// 重建 Page 1: SQLite 标准文件头 + 解密内容
SQLITE_FORMAT.copy(output, 0);
decrypted.copy(output, 16);
// 修正 page_size 字段 (offset 16-17, big-endian)
output.writeUInt16BE(PAGE_SIZE, 16);
// 保留原始 reserved-bytes-per-page 值 (byte 20)
// NTQQ SQLCipher 使用 reserved=80 用于 B-tree 布局 (usable=4016)
// 即使加密保留区 (IV+HMAC) 实际只有 48 字节
// 清零会导致 SQLite 误判溢出指针,报 "database disk image is malformed"
offset = PAGE_SIZE;
} else {
decrypted.copy(output, offset);
offset += PAGE_SIZE;
}
}
return output;
}
/**
* 解密数据库文件并写入输出路径
* @param inputPath 加密数据库路径
* @param passphrase 密钥口令
* @param outputPath 输出路径 (默认: 原路径加 _decrypted 后缀)
* @returns 输出路径,失败返回 null
*/
export function decryptDatabaseFile (
inputPath: string,
passphrase: Buffer,
outputPath?: string
): string | null {
const fileData = fs.readFileSync(inputPath);
const result = decryptDatabase(fileData, passphrase);
if (!result) return null;
if (!outputPath) {
const dotIdx = inputPath.lastIndexOf('.');
outputPath = dotIdx > 0
? inputPath.substring(0, dotIdx) + '_decrypted' + inputPath.substring(dotIdx)
: inputPath + '_decrypted';
}
fs.writeFileSync(outputPath, result);
return outputPath;
}
+29
View File
@@ -0,0 +1,29 @@
export { deriveKeys, type DerivedKeys } from './crypto';
export { decryptDatabase, decryptDatabaseFile, isEncryptedNTDB } from './decrypt';
export {
checkSqliteAvailable,
DatabaseHandle,
openDatabase,
decryptAndOpen,
listTablesFromBuffer,
listTablesFromFile,
readSingleDatabase,
scanAllDatabases,
formatScanResults,
type TableInfo,
type ColumnInfo,
type DatabaseScanResult,
type DatabaseReadOptions,
} from './reader';
export {
EXT_HEADER_SIZE,
PAGE_SIZE,
SALT_SIZE,
KEY_SIZE,
IV_SIZE,
HMAC_SIZE,
RESERVE_SIZE,
KDF_ITERATIONS,
FAST_ITERATIONS,
HMAC_MASK,
} from './constants';
+425
View File
@@ -0,0 +1,425 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { decryptDatabase, decryptDatabaseFile, isEncryptedNTDB } from './decrypt';
type DatabaseSyncType = import('node:sqlite').DatabaseSync;
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean; }) => DatabaseSyncType;
type SQLInputValue = import('node:sqlite').SQLInputValue;
let _DatabaseSync: DatabaseSyncCtor | null = null;
let _sqliteChecked = false;
async function loadSqlite (): Promise<DatabaseSyncCtor | null> {
if (_sqliteChecked) return _DatabaseSync;
_sqliteChecked = true;
try {
const mod = await import('node:sqlite');
_DatabaseSync = mod.DatabaseSync as unknown as DatabaseSyncCtor;
return _DatabaseSync;
} catch {
_DatabaseSync = null;
return null;
}
}
function getDatabaseSync (): DatabaseSyncCtor {
if (!_DatabaseSync) {
throw new Error(
'node:sqlite 不可用。请使用 Node.js 22+ 并添加 --experimental-sqlite 标志,' +
'或调用 checkSqliteAvailable() 检查可用性。'
);
}
return _DatabaseSync;
}
export async function checkSqliteAvailable (): Promise<boolean> {
const ctor = await loadSqlite();
return ctor !== null;
}
// ======================== 数据库连接管理 ========================
/** 数据库连接句柄,封装 DatabaseSync 实例 */
export class DatabaseHandle {
private db: DatabaseSyncType;
private _closed = false;
readonly filePath: string;
readonly readOnly: boolean;
constructor (filePath: string, readOnly = false) {
const DB = getDatabaseSync();
this.db = new DB(filePath, { readOnly });
this.filePath = filePath;
this.readOnly = readOnly;
}
/** 数据库是否已关闭 */
get closed (): boolean {
return this._closed;
}
private ensureOpen (): void {
if (this._closed) throw new Error('数据库已关闭');
}
/**
* 执行 SELECT 查询,返回所有结果行
* @param sql SQL 语句
* @param params 绑定参数 (位置参数数组 或 命名参数对象)
*/
query<T = Record<string, unknown>> (sql: string, params?: SQLInputValue[] | Record<string, SQLInputValue>): T[] {
this.ensureOpen();
const stmt = this.db.prepare(sql);
if (params) {
if (Array.isArray(params)) {
return stmt.all(...params) as T[];
}
return stmt.all(params) as T[];
}
return stmt.all() as T[];
}
/**
* 执行 SELECT 查询,仅返回第一行
* @param sql SQL 语句
* @param params 绑定参数
*/
queryOne<T = Record<string, unknown>> (sql: string, params?: SQLInputValue[] | Record<string, SQLInputValue>): T | undefined {
this.ensureOpen();
const stmt = this.db.prepare(sql);
if (params) {
if (Array.isArray(params)) {
return stmt.get(...params) as T | undefined;
}
return stmt.get(params) as T | undefined;
}
return stmt.get() as T | undefined;
}
/**
* 执行非查询 SQL (INSERT/UPDATE/DELETE/CREATE 等)
* @param sql SQL 语句
* @param params 绑定参数
* @returns 受影响的行数信息
*/
execute (sql: string, params?: SQLInputValue[] | Record<string, SQLInputValue>): { changes: number | bigint; lastInsertRowid: number | bigint; } {
this.ensureOpen();
const stmt = this.db.prepare(sql);
if (params) {
if (Array.isArray(params)) {
return stmt.run(...params);
}
return stmt.run(params);
}
return stmt.run();
}
/** 列出所有表的信息 */
listTables (): TableInfo[] {
this.ensureOpen();
return extractTablesInfo(this.db);
}
/** 获取指定表的列信息 */
getTableColumns (tableName: string): ColumnInfo[] {
this.ensureOpen();
const cols = this.db.prepare(`PRAGMA table_info([${tableName}])`).all() as {
cid: number; name: string; type: string; notnull: number; pk: number;
}[];
return cols.map(col => ({
cid: col.cid,
name: col.name,
type: col.type,
notnull: col.notnull === 1,
pk: col.pk === 1,
}));
}
/** 获取表的行数 */
getRowCount (tableName: string): number {
this.ensureOpen();
const cnt = this.db.prepare(`SELECT count(*) as cnt FROM [${tableName}]`).get() as { cnt: number; } | undefined;
return cnt?.cnt ?? 0;
}
/** 关闭数据库连接 */
close (): void {
if (!this._closed) {
this.db.close();
this._closed = true;
}
}
}
/**
* 打开一个已解密的 SQLite 数据库文件
* @param filePath 解密后的 .db 文件路径
* @param readOnly 是否以只读模式打开 (默认 true)
*/
export function openDatabase (filePath: string, readOnly = true): DatabaseHandle {
return new DatabaseHandle(filePath, readOnly);
}
/**
* 解密并打开一个加密的 NTQQ 数据库
* @param dbPath 加密数据库路径
* @param passphrase 密钥口令
* @param cacheDir 解密缓存目录 (可选)
* @param readOnly 是否只读 (默认 true)
*/
export function decryptAndOpen (
dbPath: string,
passphrase: Buffer,
cacheDir?: string,
readOnly = true
): DatabaseHandle {
const filename = path.basename(dbPath);
if (cacheDir) {
const cachedPath = path.join(cacheDir, filename);
// 已存在缓存直接打开
if (fs.existsSync(cachedPath)) {
return new DatabaseHandle(cachedPath, readOnly);
}
fs.mkdirSync(cacheDir, { recursive: true });
const outPath = decryptDatabaseFile(dbPath, passphrase, cachedPath);
if (!outPath) throw new Error(`解密失败: ${dbPath}`);
return new DatabaseHandle(outPath, readOnly);
}
// 无缓存目录: 解密到临时文件
const tmpPath = path.join(os.tmpdir(), `napcat_db_${Date.now()}_${Math.random().toString(36).slice(2)}.db`);
const fileData = fs.readFileSync(dbPath);
const decryptedBuf = decryptDatabase(fileData, passphrase);
if (!decryptedBuf) throw new Error(`解密失败: ${dbPath}`);
fs.writeFileSync(tmpPath, decryptedBuf);
return new DatabaseHandle(tmpPath, readOnly);
}
/** 单个数据库表信息 */
export interface TableInfo {
name: string;
type: string; // 'table' | 'view' | ...
rowCount: number;
columns: ColumnInfo[];
}
/** 列信息 */
export interface ColumnInfo {
cid: number;
name: string;
type: string;
notnull: boolean;
pk: boolean;
}
/** 单个数据库扫描结果 */
export interface DatabaseScanResult {
/** 数据库文件名 */
filename: string;
/** 原始加密文件路径 */
sourcePath: string;
/** 解密后缓存文件路径 (仅当指定了 cacheDir 时) */
cachePath?: string;
success: boolean;
error?: string;
tables: TableInfo[];
}
/** 扫描/读取选项 */
export interface DatabaseReadOptions {
/** 解密后数据库缓存目录,不设置则使用临时文件 */
cacheDir?: string;
/** 是否跳过已存在的缓存文件 (默认 true) */
skipExistingCache?: boolean;
}
/**
* 从 DatabaseSync 对象提取表信息
*/
function extractTablesInfo (db: DatabaseSyncType): TableInfo[] {
const tables: TableInfo[] = [];
const masterRows = db.prepare(
"SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') ORDER BY name"
).all() as { name: string; type: string; }[];
for (const { name: tableName, type: tableType } of masterRows) {
// 获取列信息
const columns: ColumnInfo[] = [];
try {
const cols = db.prepare(`PRAGMA table_info([${tableName}])`).all() as {
cid: number; name: string; type: string; notnull: number; pk: number;
}[];
for (const col of cols) {
columns.push({
cid: col.cid,
name: col.name,
type: col.type,
notnull: col.notnull === 1,
pk: col.pk === 1,
});
}
} catch {
// PRAGMA 可能对某些表失败
}
// 获取行数
let rowCount = 0;
try {
const cnt = db.prepare(`SELECT count(*) as cnt FROM [${tableName}]`).get() as { cnt: number; } | undefined;
rowCount = cnt?.cnt ?? 0;
} catch {
rowCount = -1;
}
tables.push({
name: tableName,
type: tableType,
rowCount,
columns,
});
}
return tables;
}
/**
* 打开一个解密后的 SQLite 文件,列出所有表和列信息
*/
export function listTablesFromFile (filePath: string): TableInfo[] {
const DB = getDatabaseSync();
const db = new DB(filePath, { readOnly: true });
try {
return extractTablesInfo(db);
} finally {
db.close();
}
}
export function listTablesFromBuffer (data: Buffer): TableInfo[] {
const tmpPath = path.join(os.tmpdir(), `napcat_db_${Date.now()}_${Math.random().toString(36).slice(2)}.db`);
try {
fs.writeFileSync(tmpPath, data);
return listTablesFromFile(tmpPath);
} finally {
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
}
/**
* 解密并读取单个数据库文件
*
* @param dbPath 加密数据库文件路径
* @param passphrase 密钥口令
* @param options 可选配置 (缓存目录等)
* @returns 数据库扫描结果
*/
export function readSingleDatabase (
dbPath: string,
passphrase: Buffer,
options?: DatabaseReadOptions
): DatabaseScanResult {
const filename = path.basename(dbPath);
const result: DatabaseScanResult = {
filename,
sourcePath: dbPath,
success: false,
tables: [],
};
try {
// 如果指定了缓存目录,且缓存文件已存在,直接读取
if (options?.cacheDir) {
const cachedPath = path.join(options.cacheDir, filename);
result.cachePath = cachedPath;
if (options?.skipExistingCache !== false && fs.existsSync(cachedPath)) {
result.tables = listTablesFromFile(cachedPath);
result.success = true;
return result;
}
}
const fileData = fs.readFileSync(dbPath);
if (!isEncryptedNTDB(fileData)) {
result.error = 'Not an encrypted NTQQ database';
return result;
}
// 有缓存目录: 解密写入文件再读取
if (options?.cacheDir) {
fs.mkdirSync(options.cacheDir, { recursive: true });
const outPath = decryptDatabaseFile(dbPath, passphrase, result.cachePath);
if (!outPath) {
result.error = 'Decryption failed';
return result;
}
result.cachePath = outPath;
result.tables = listTablesFromFile(outPath);
} else {
// 无缓存目录: 解密到内存再用临时文件读取
const decryptedBuf = decryptDatabase(fileData, passphrase);
if (!decryptedBuf) {
result.error = 'Decryption returned null';
return result;
}
result.tables = listTablesFromBuffer(decryptedBuf);
}
result.success = true;
} catch (e) {
result.error = e instanceof Error ? e.message : String(e);
}
return result;
}
export function scanAllDatabases (
dbDir: string,
passphrase: Buffer,
options?: DatabaseReadOptions
): DatabaseScanResult[] {
const results: DatabaseScanResult[] = [];
if (!fs.existsSync(dbDir)) return results;
const files = fs.readdirSync(dbDir).filter(f => f.endsWith('.db')).sort();
for (const filename of files) {
const filePath = path.join(dbDir, filename);
const result = readSingleDatabase(filePath, passphrase, options);
results.push(result);
}
return results;
}
/**
* 格式化扫描结果为可读字符串
*/
export function formatScanResults (results: DatabaseScanResult[]): string {
const lines: string[] = [];
let totalTables = 0;
let successCount = 0;
for (const db of results) {
if (db.success) {
successCount++;
const cacheNote = db.cachePath ? ` → ${db.cachePath}` : '';
lines.push(`=== ${db.filename} (${db.tables.length} tables)${cacheNote} ===`);
for (const table of db.tables) {
totalTables++;
const colList = table.columns.map(c => `${c.name}:${c.type || '?'}`).join(', ');
lines.push(` ${table.name}: ${table.rowCount} rows [${colList}]`);
}
} else {
lines.push(`=== ${db.filename}: SKIP (${db.error}) ===`);
}
lines.push('');
}
lines.push(`Summary: ${successCount}/${results.length} databases decrypted, ${totalTables} total tables`);
return lines.join('\n');
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}