nt_helper.node是一个用 Rust(napi-rs)编译的原生 N-API 模块,已经把「QQ 进程检测、数据库解密 / 直查 / 导出、ptlogin2 cookie、只读内存取会话物料、原生 SSO 发包、商城表情 / 字体 / 装扮资源」这些重活全部封装好了。Node 侧(主进程、@weq/db、@weq/protocol、各种 worker)直接加载调用即可,不要重新实现里面的任何一个能力。下面每个接口往下翻——如果在写代码时发现某件事「看起来很底层」,大概率这里已经提供了现成函数。先搜文档,再决定要不要动手。
- 加载方式:纯 Node 原生模块(N-API),
require()/createRequire()直接加载,无需 rebuild、无需 Electron 主线程。 - 文件名来源:Cargo
[lib] name = "nt_helper",crate-type = ["cdylib"],产物以nt_helper.node落在native/<platform>/<arch>/下。 - 命名约定:导出函数一律为 camelCase(NAPI 自动由 Rust 的 snake_case 转换),下文的接口名均为直接可用的 JS 名称。返回的对象字段同理(
page_hmac_algorithm→pageHmacAlgorithm)。 - 异步:
async原生函数在 Node 侧返回 Promise;内部用spawn_blocking把耗时工作丢到阻塞线程池,不阻塞 JS 事件循环。 - 跨平台:同一套 JS 接口,Windows / Linux / macOS 全部可用;个别接口输入与行为按平台有差异,下面逐个标注。
import { createRequire } from 'node:module';
const requireFn = createRequire(__filename);
const nt = requireFn('native/linux/x64/nt_helper.node');仓库里不带
native/**/nt_helper.node(历史太大,已拆到 nt_helper_release 分发)。 dev 克隆先跑pnpm native:fetch取最新构建,细节见 原生二进制与装扮资源的分发。
所有功能函数在真正干活前都会内部调用 logger::init_logger() 与环境校验(有效期检查)。务必按下面的顺序初始化:
- 先
setLogPath(可选但建议):配置日志路径,否则日志落到默认位置。 - 再
getInitStatus():返回初始化状态;0= 可用,继续;- 非
0= 环境校验失败(构建过期 / 损坏 / 源码改动被检测到),此时多数接口会直接抛错("Environment validation failed")或返回一个默认的“失败”值,详见 §2.
getInitStatus()可以且应该在每次加载require之后调用一次,作为对「模块是否可用」的硬性检查(如attach_worker.ts的做法)。
dev 构建无时间戳 → 跳过校验:本地
cargo/npm build出的.node不带BUILD_TIMESTAMP,getInitStatus()直接返回0,没有任何有效期 / LICENSE 限制;带时间戳的 CI release 构建才会走有效期检查。有效期长度只由 nt_helper 的 Rust 定义一处,查询用getBuildInfo()(返回buildTime/expireTime/validitySecs/isDev)。
| JS 函数 | 参数 | 返回 | 说明 |
|---|---|---|---|
getInitStatus() |
— | number |
全局初始化状态:0=可用,-1=过期,-200=损坏,-201=被篡改,99=未知。 |
getBuildInfo() |
— | BuildInfo |
构建时间 / 有效期窗口:{ buildTime: number | null; expireTime: number | null; validitySecs: number; isDev: boolean }(Unix 秒)。expireTime = buildTime + validitySecs。有效期长度只在 native 侧定义一处。不经过初始化校验,过期后仍可查询(用于驱动更新提示)。 |
setLogPath(path) |
path: string |
Promise<void> |
配置日志输出路径;内部 logger::set_log_path。 |
resolveAppidFromMajor(majorPath) |
majorPath: string |
Promise<AppidInfo> |
从 QQ NT 的 resources/app/major.node 直接扫描出 appid / qua / version / build,所见即所得,无静态版本表。 |
convertFont(inputPath, outputPath, options?) |
俩字符串路径 + 可选 ConvertFontOptions |
Promise<string> |
FTF → 标准 TTF 转换;本来就是 TTF 则直接拷贝。默认还会把 brsh/cglf 编译成彩色字体(COLR v1 + CPAL v0)、把 eimg 炫彩帧导到 <输出名>_assets/(frame_<槽位>.png)、并用本机 ots-sanitize 预检产物。返回说明消息,如 converted FTF to TTF; exported 109 eimg frame(s) (350x141) to …; OTS ok。 |
getFontPipelineVersion() |
— | number |
native 侧字体转换链的版本号(font::FONT_PIPELINE_VERSION):改 src/font/** 的产物字节就 +1。宿主把产出 fonts/<id>.ttf 时的这个号写进 sidecar,读缓存时对不上就地重解 —— 这样「native 修了字体的表处理」不必再等宿主手改版本号(hdmx/LTSH 那次就是漏在这里)。 |
getMarketFaceKey(packetId) |
packetId: string |
Promise<MarketFaceKey | null> |
恢复商城表情包 QQTEA 密钥(自包含:抓 android.json 提示 → xydata 快路径 → 采样爆破)。返回 null 表示拿不到。 |
queryDressResourceUrl(dtype, itemId, name) |
三个字符串 | DressResourceUrl | null |
纯本地查装扮资源下载 URL(font/bubble/widget × 部件名),不联网、不依赖协议。null = 本地包缺失或未命中(此时交给协议兜底)。 |
携带数据的封箱类型
AppidInfo = { appid: string; qua?: string; version?: string; build?: string }BuildInfo = { buildTime: number | null; expireTime: number | null; validitySecs: number; isDev: boolean }MarketFaceKey = { timestamp: number; key: string; source: 'xydata' | 'brute-force' }DressResourceUrl = { url: string; size: number }ConvertFontOptions = { color?: boolean; colorChars?: string; assetsDir?: string; checkOts?: boolean }(缺省即全开;assetsDir: ''关图包,color: false关彩色,checkOts: false关校验)。早于该开关的 nt_helper 会忽略第三个参数(只做转换),调用方不必探测版本。
这些是纯只读探测,不注入、不改库。注意:大部分 probe 函数失败时不抛错,而是返回带 success / msg 字段的“失败”对象(不满足某先决条件时会返回与真实失败相同形状的默认值),调用方按字段判断即可。
| JS 函数 | 参数 | 返回 | 说明 |
|---|---|---|---|
getQqProcesses(headless?) |
headless?: boolean |
Promise<number[]> |
全部 QQ 主进程 PID。macOS 按 headless 二选一:默认走 bundle id 直查(需 GUI),true 走 libproc 全量枚举。 |
probeQqLoginInfo(pid) |
pid: number |
Promise<QqPortLoginInfo | null> |
通过本地端口探测登录态 / UIN / 昵称。null = 没拿到信息。 |
probePtLoginPort(pid) |
pid: number |
PtLoginPortProbeResult |
探测 pt_login 端口(奇数 HTTPS / 偶数 HTTP,优先 HTTPS)。 |
probeDbLock(dbPath) |
dbPath: string |
DbLockProbeResult |
反查哪些进程占用了数据库文件(success+locked+holders)。Windows 用 Restart Manager,Unix 用 fcntl 写锁探测。只读。 |
isQqLoggedIn(uin, baseDir?, uid?) |
uin, baseDir?, uid? |
boolean |
某账号是否登录中。平台差异:Windows 用 uin 查命名互斥体;Linux/macOS 用 baseDir + uid 探测 nt_msg.db 的 fcntl 锁(缺任一 false)。 |
返回类型
QqPortLoginInfo = { port: number; uin: string; uid?: string; nickName?: string; loggedIn: boolean }PtLoginPortProbeResult = { success: boolean; msg: string; port: number }DbLockProbeResult = { success: boolean; msg: string; locked: boolean; holders: DbLockHolder[] }DbLockHolder = { pid: number; name: string }
桌面常规轮询在 macOS 使用 @weq/platform 的有界异步 pgrep + lsof 探测,仍要求
QQ 进程实际持有该账号的 nt_msg.db,不以进程存在或端口信息代替账号归属。
同账号并发调用合并,不同账号独立探测,最多保留 8 个未退出的探测任务。
探测超时、启动失败和异常退出通过拒绝 Promise 表示“未能确认”,不返回离线结果;
正常查无持有者才返回 null。账号监控遇到单次空结果会在下一轮复核,连续两次未发现
才清理在线凭据;已确认在线后遇到探测错误,最多保留 30 秒状态并暂停本轮采集,
持续不可用则转离线,防止永久复用旧凭据。失败记录 online-probe-failed,下线记录
reason(no-db-holder 或 probe-unavailable),便于区分真正未发现与探测故障。
登录已与 QQ 进程解耦:不读内存、不注入、不提权,直接把 login.db 里缓存的 a1(扫码路径不需要)
和设备 guid 传给 native,由 native 直连登录服务器(wtlogin)换 a2/d2/d2key。有 a1 → 快登
(服务端判异常设备时转手机点确认);无 a1 → 扫码。快登失败由调用方回退到扫码。
| JS 函数 | 参数 | 返回 | 说明 |
|---|---|---|---|
quickLogin(options, onEvent?) |
QuickLoginOptions + 进度回调 |
Promise<QuickLoginResult> |
用缓存 a1 换票据;keyMeta 给了就顺带发 0xcde_2 取 dbKey。 |
qrLogin(options, onEvent?) |
同上(a1 可留空) |
Promise<QuickLoginResult> |
出二维码等扫码/确认,再走经典 wtlogin.login 换票据;同样顺带 dbkey / pskey。 |
QuickLoginOptions:uin / a1 / guid / appId / subAppId / os / platform /
deviceName / kernelVersion(appId 是 EasyLogin 常量、subAppId 必须扫描 major.node),
另可选 appName / qua / clientVersion / wrapperPath(签名用)/ uid /
keyMeta(nt_msg.db 头 0x2f..0xaf 的 128 字节)/ pskeyDomains。
QuickLoginResult:a2 / d2 / d2Key / uin,外加 dbKey?(0xcde_2 结果)与
pskey?: Record<string, string>(0x102a_0 结果,域 → p_skey;未请求或没拿到时缺省)。
pskeyDomains 走的是同一条登录连接、属尽力而为:取不到只记日志,不会让登录失败。
一个域名一轮请求(服务端一轮只回一个域,真机抓包里客户端也每轮只带一个域),所以两个域就是两轮
0x102a_0。WeQ 侧默认要 vip.qq.com(首页装扮 zb.vip.qq.com 用)与 qzone.qq.com
(QQ 空间 Web cgi / 空间窗口用)。
onEvent(err, event) 推送进度:state(阶段文案)、unusual-device(异常设备需确认,
带 unusualDeviceCheckSig / unusualDeviceQrSig / uinToken)、qr-code
(qrUrl / qrImage)、qr-state(state / stateCode / uin)。
凭据获取的 HTTP 流程只有 native 这一份实现,TS 侧只负责选域、缓存和业务编排, 不要自己发 xlogin / jump 请求或解析 JSONP。
取票顺序:OIDB 优先、本地 pt_login 兜底。 本地快速登录端口
(xlogin → pt_get_uins → pt_get_st → jump → check_sig)在账号安全等级
不足 / 需要手机确认时会直接返回 403,且各平台监听情况不一;clientKey 走的
OIDB 0x102a_1 复用的是同机 QQ 已在维持的会话,更稳定、更普适。
ptFetchCredentials 的三级链路:
clientKey(OIDB0x102a_1为主、本地端口兜底)→ ptlogin2jump,沿途收下 整条 302 链的 cookie jar。这一级同时给出域无关的skey、目标域的p_skey和 含风控 cookie 的完整cookie。- 本地 pt_login 端口跑完整登录流程(固定四域 / 邮箱)。
- OIDB
0x102a_0只取p_skey(拿不到skey,也没有风控 cookie)。
任一级拿到 p_skey 即返回;全部失败时 msg 会逐级累积各自的失败原因,不再
只报最后一步。未注入 / 未登记 setSsoSession 时第 1、3 级会立即报错(不是超时),
此时只能靠第 2 级的本地端口。
| JS 函数 | 参数 | 返回 | 说明 |
|---|---|---|---|
ptFetchCredentials(pid, uin, domain) |
pid / uin / 目标域 |
Promise<PtCredentialsResult> |
三级链路(clientKey jump → 本地 ptlogin2 → OIDB 0x102a_0),固定四域与任意域一致;邮箱域走第 2 / 3 级取面板 p_skey。 |
ptFetchClientKey(pid, uin) |
pid / uin |
Promise<PtClientKeyResult> |
OIDB 0x102a_1 优先,失败回退本机快速登录端口。 |
ptFetchJumpUrl(pid, uin, landingUrl) |
pid / uin / https 落地页 |
Promise<PtJumpUrlResult> |
生成一次性免登录跳转地址;clientKey 只应在本次点击使用。 |
ptFetchMailSession(pid, uin) |
pid / uin |
Promise<PtMailSessionResult> |
邮箱登录:优先使用客户端 delegateCode → auth_type=7 → sid,兼容 ptlogin,并返回独立提醒票据 panelPskey 和可灌入 CookieJar 的 cookie。 |
返回类型
PtCredentialsResult = { success; msg; skey; pskey; cookie; errorKind }PtClientKeyResult = { success; msg; clientKey; keyIndex; ttlSeconds }PtJumpUrlResult = { success; msg; url; clientKey; keyIndex }PtMailSessionResult = { success; msg; sid; panelPskey; cookie; errorKind }
cookie 是 native 在跳转链上收到的完整 Cookie 头(含 pt4_token / RK / ptcz
等风控字段),调用方应优先原样发送;拿不到时才回退到 uin/skey/p_uin/p_skey
四字段。panelPskey 是邮箱面板提醒专用票据,网页登录清掉 CookieJar 后仍需独立保留。
ptFetchSkey(port, uin) / ptFetchPskey(port, uin, domain) 是底层兼容接口,
新代码优先用 ptFetchCredentials。
获取 QQ NT 加密库的解密密钥,三条路线都在这里,优先复用,别自己重写内存扫描。
| JS 函数 | 参数 | 返回 | 说明 |
|---|---|---|---|
scanKeyFromDatabase(dbPath, pid) |
dbPath: string, pid: number |
Promise<KeyScanResult> |
零注入内存扫描拿 raw master key,用 dbPath 过滤候选。扫描逻辑移植自 x_key_scanner(含强力模式:锚点/簇扫描失败时回退扫整个内存),候选并行校验。 |
scanKeyFromDatabaseWithProgress(dbPath, pid, onProgress?) |
同上 + 进度回调 | Promise<KeyScanResult> |
同上,额外通过 onProgress(err, progress) 持续回报两阶段进度。可选能力(老产物没有,调用方需回退到 scanKeyFromDatabase)。 |
testDatabaseKey(dbPath, key) |
dbPath, key |
Promise<KeyTestResult> |
试 key 是否能解开库,穷举 page-HMAC × KDF-HMAC 全部 12 种组合,返回能解开的那组算法。用于不知道 algo 时先探测一次。 |
decryptLoginDb(loginDbPath, algo) |
路径 + CipherAlgo |
Promise<LoginAccount[]> |
走 offset VFS 解密 login.db 拿缓存登录账号,不落临时明文文件。algo 可先用 testDatabaseKey 探测。 |
getGuildDbKey(dbPath, uin) |
路径 + uin |
Promise<string> |
计算 QQ 频道(gpro)库的密钥:扫描库内 salt + 特定 md5 公式。 |
本地原生修复版本 2.2.3 的两种 scanKeyFromDatabase* 接口仅读取数据库前
5120 字节(QQ 包装头 + 首个加密页)用于候选校验,短文件或读取错误返回失败,
不再整库分配。正式 pin 尚未更新,使用前须核验实际加载的产物;复现、检查脚本及
交付边界见大库密钥扫描修复。
返回类型
KeyScanResult = { success: boolean; key?: string; keyContextHex?: string; error?: string }key:恢复出的 16 字节 raw master key;keyContextHex:密钥前后各 256 字节内存窗口的 hex(定位佐证)。
KeyScanProgress = { phase: string; percent: number; message: string }phase:'anchor'(锚点/簇扫描)或'strong'(回退全内存扫描);percent是当前阶段内的 0–100(阶段切换会从 0 重新开始),message是给用户看的短句。
KeyTestResult = { success: boolean; pageHmacAlgorithm?: string; kdfHmacAlgorithm?: string }- 成功时
pageHmacAlgorithm∈'none' | 'SHA1' | 'SHA256' | 'SHA512',kdfHmacAlgorithm∈'SHA1' | 'SHA256' | 'SHA512'。
- 成功时
LoginAccount = { uin: string; uid: string; avatarUrl: string; userName: string; a1Key: string; lastLoginAt: number }
对 QQ NT 加密库(SQLCipher v4 + 1024 字节 wrapper 头)的读写与导出。全部走自定义 offset VFS 透明跳过文件头,连接默认缓存 —— 同一 dbPath 首次打开解密一次,后续调用复用句柄,极大省开销。better-sqlite3 在 running code 里零引用,读库统一走这里。
SQLite 的保留页按文件设置:QQ 库因 1024 字节头保留 262144,标准 SQLite 保留 262145。nt_helper 从文件头自动识别布局,同一进程可以同时打开两类库与解密产物,不再需要子进程。详见 QQ 数据库布局:按文件设置保留页。
| JS 函数 | 参数 | 返回 | 说明 |
|---|---|---|---|
executeSql(dbPath, sql, params?) |
路径 + SQL + 可选参数 | Promise<SqlValue[][]> |
只读查询(offset VFS,无密钥)。注意:这只适用于无需 SQLCipher 密钥的库;带密钥的库首次需要用 executeSqlWithKey 领证,之后才能用 executeSql 复用。 |
executeSqlWithKey(dbPath, sql, key, algo, params?) |
+ key + CipherAlgo |
Promise<SqlValue[][]> |
带密钥查询。密钥只消费一次:首个调用驱动 PRAGMA cipher 舞步并缓存连接,之后可省略密钥用 executeSql。 |
executeSqlWrite(dbPath, sql, params?) |
同上(写) | Promise<number> |
写操作,返回受影响行数。带空闲检测 + BEGIN IMMEDIATE 重试。谨慎使用:写前注意备份。 |
executeSqlWriteWithKey(...) |
+ key + algo |
Promise<number> |
带密钥的写操作。 |
closeDb(dbPath) |
路径 | number |
关闭该路径所有缓存连接,返回关闭数。登出 / 释放文件句柄时调用。 |
closeAllDb() |
— | number |
关闭全部缓存连接。 |
fastDecryptDatabase(dbPath, outPath, key, algo) |
路径们 + key + algo | void |
快速解密为标准布局的明文 SQLite(直接落盘),可直接被任何 SQLite 工具打开。 |
safeDecryptDatabase(dbPath, outPath, key, algo) |
同上 | void |
安全解密:所有读走 SQLite(offset VFS + sqlcipher_export),长导出期间不怕 QQ checkpoint 撕页;无中间文件。产物同样是标准布局明文。 |
checkDatabaseHealth(dbPath, key, algo) |
路径 + key + algo | Promise<DatabaseHealthResult> |
PRAGMA integrity_check 体检;整体失败时逐表出结果。 |
recoverDatabase(options, onProgress?) |
RecoverOptions + 可选进度回调 |
Promise<RecoverReport> |
重建损坏库:坏页扫描 → 解密 → SQLite recover → 重新加密 → 补 QQ 头 → 自检。源库只读。sourceLayout / targetLayout 可选(默认都 qq);把旧版误修复的 standard 布局迁回 QQ 时传 sourceLayout: 'standard', targetLayout: 'qq',产物报告会回显实际布局。旧产物会静默忽略这两个参数、也不回显布局,调用方必须在替换前核对。 |
databaseRepairStorageVersion()(可选) |
无 | number |
返回 1 表示重建后提前释放明文源库、通过 offset VFS 直接加密带头产物;方法缺失按旧模块预算预检。能力标记与实际加载模块绑定,不以应用版本推断。 |
executeSqlSalvage(dbPath, sql, params?, level?) |
路径 + SQL + 可选参数 + 宽容级别 | Promise<SalvageQueryOutcome> |
宽容只读查询(独立的 salvage 连接)。不因“损坏”抛异常,而是返回 ok:false + 错误码,由调用方决定放弃还是切更小的一段;其它错误照旧抛。级别 ≥ 1 时遇到损坏会换访问路径(NOT INDEXED)重试。 |
executeSqlSalvageWithKey(dbPath, sql, key, algo, params?, level?) |
同上 + key + algo | 同上 | 带密钥版本,语义相同。 |
executeSqlSalvageScan(dbPath, sql, params, options, level?) |
+ SalvageScanOptions |
Promise<SalvageScanOutcome> |
分块容错扫描(L2/L3):读不出来的块先换访问路径、再二分到 minSpan,仍坏就记成“跳过区间”;ok:false(超预算 / 未授权 L2 / 表被隔离)时行集被清空,不许把部分数据当完整数据用。 |
executeSqlSalvageScanWithKey(...) |
同上 + key + algo | 同上 | 带密钥版本。 |
closeSalvageDb(dbPath) |
路径 | number |
只释放该库的 salvage 只读连接(严格连接一律不动),并清掉该库的 L3 隔离记录。用户把宽容级别调回严格时用它。 |
scanBadPages(dbPath, key, algo) |
路径 + key + algo | Promise<BadPageScanResult> |
逐页复算页 HMAC,产出物理坏页清单(“坏页地图”)。⚠ usedHmac=false 时 badPages 必为空 —— 那不是“库是好的”,而是“这个库没开页 HMAC(或压根是明文库,见 plaintext),地图没有意义”。只读密文。 |
listQuarantinedTables() |
— | QuarantinedTable[] |
当前被 L3(整表放弃)隔离的表。进程内状态,条目默认 300 秒自动失效(到点自动再试)。 |
clearQuarantinedTables(dbPath?, table?) |
可选两级过滤 | number |
清掉隔离记录让那些表立刻再试。只给 dbPath → 清该库;再加 table → 只清那一张;都不给 → 全清。 |
调用解密接口前使用 @weq/native 的 selectDatabaseDecryptMethod(dbPath, mode):
它保留用户指定的安全模式,并把 ≥ 512 MiB 的快速解密请求切换到安全模式。
搜索索引重建和数据库导出共用该选择逻辑,避免快速接口整库分配内存导致 Electron
进程崩溃(worker 线程也不能隔离原生内存分配失败)。
几乎所有带密钥操作都要传 algo(page-HMAC × KDF-HMAC 组合)。先用 testDatabaseKey 探测未知库得到这对算法,再喂给本节的各函数:
{ pageHmacAlgorithm: 'SHA1', kdfHmacAlgorithm: 'SHA1' }
// page: 'none' | 'SHA1' | 'SHA256' | 'SHA512'
// kdf : 'SHA1' | 'SHA256' | 'SHA512'字符串大小写不敏感。
params 支持 null | bigint | number | string | Buffer | Uint8Array;返回行内每个单元格为:
- NULL →
null - INTEGER →
bigint(保留完整 i64 精度,QQ 的 µs/ns 时间戳不会溢出) - REAL →
number - TEXT →
string - BLOB →
Buffer
executeSqlWithKey 返回行示例:
[
[1n, "文本", Buffer.from([...]), 123n],
...
]
KeyTestResult / DatabaseHealthResult = { healthy: boolean; corruptedTables: string[] }。
上面四个 *Salvage* 接口是同一套降级机制的四档。级别永远由调用方(用户设置)给定,
native 不自己猜;级别 0 时读取与今天逐字节相同。
| 级别 | 含义 | 会丢数据吗 | 生效范围 |
|---|---|---|---|
| 0 | 严格:任何错误原样报出 | —(默认) | 默认路径 |
| 1 | 换访问路径(NOT INDEXED),只改 SQLite 的访问方式 |
不会 | 走宽容入口的读查询 |
| 2 | 跳过坏页覆盖的行区间(分块扫描) | 会 | 只有满足下面第 1 条契约的查询(WeQ 接在导出链路上) |
| 3 | 整表放弃:隔离读不动的表,保证其它表可用 | 会 | 在级别 2 的基础上启用,(db_path, table) 粒度 |
阈值与代价都是量出来的,不是估的;写在这里以免界面或文档再一次把能力写宽:
- 聚合查询救不了,级别再高也一样。SQLite 没有“跳过坏页继续执行”的开关 —— 任何语句
一旦碰到坏页就整条以
SQLITE_CORRUPT中止。实测同一条GROUP BY聚合在级别 0 / 1 / 2 / 3 下都是kind=corrupt, code=11, 0 行(约 120–230 ms)。年度报告、群分析这类整表聚合因此 完全不受宽容级别影响,只有修复数据库才能恢复。 - 级别 1 只在“坏在索引上”时有救。它的做法是把语句改写成
NOT INDEXED重试一次; 若坏的是数据页本身,换哪条路都要读那一页 —— 实测坏页同时在索引路径与全表路径上时, 重试 121 ms 后仍然失败。 - 级别 2 的代价按“坏键”线性增长:二分地板是
span = 1,一个读不出来的键约 215 ms (两次 native 尝试,~110 ms/次)。所以一个 4096 宽的导出窗口若横跨很宽的坏区间,第一块 就要十几分钟才走得完;maxSkippedSpan决定的是“要假死多久才肯放弃”。WeQ 的驱动层因此 故意收紧到 20 / 500(native 那份 256 / 8192 的默认值留给明确要求长扫的场景), 约 108 秒就会干净地按严格语义失败。 - 级别 2 的“跳过”是键轴上的,不是行上的:
GROUP BY/ JOIN 这类没有整数键轴的查询 无法切块,契约对不上时是报错,不会给出一份“看着完整、其实位置不明”的结果。 - 级别 3 的隔离粒度是整张表:
(db_path, table)连续失败 3 次(DEFAULT_MAX_FAILURES) 即隔离,存活 300 s(DEFAULT_TTL_SECS)。隔离期内这张表所有读都会短路返回quarantined—— 包括本来健康的会话,所以界面必须说清这一点。
几条必须遵守的契约:
- 分块扫描的 SQL 契约(native 不解析 SQL):末两个
?是区间边界(lo, hi]、结果按 key 升序、第一列必须是整数 key(一般是rowid)。seekSql(已知键探针)的过滤条件 必须与扫描语句一字不差,否则会按契约违规报错。 - 只有损坏进宽容路径:按 native 返回的错误码判定(11 = CORRUPT / 26 = NOTADB),
不看错误文案;开库失败、
BUSY、权限、语法错一律照旧抛。 skipped/skippedSpan不是行数:区间内部有多少行读不出来就无从得知,而键跨度 也不是行数上界(同一个键可能对应多行 —— 共享seq的灰条、贴表情)。报告里只能说 “这一段读不出来”,不能说“最多丢 N 行”。- 预算是上界,不是许可:
maxSkippedRanges/maxSkippedSpan用尽(native 默认 256 / 8192, 相邻同类区间会先合并)就按严格语义整体失败,绝不“越救越多”。驱动层默认更紧(20 / 500, 理由见 5.4.1)。 跨度按区间几何(hi - lo)累计,不照抄 native 单次返回的skippedSpan: 后者会把本次调用里已经知道的区间再算一遍;同一段坏区间被相邻窗口各报一次时,账本只记 新增的那一截(实测 7 键的洞会被重复上报成 2 处 / 跨度 14)。 quarantined与损坏分开:ok=false && quarantined=true表示“这次根本没查”,errorKind是"quarantined"、errorCode为null—— 调用方的账本/报告必须与 “替代路径也救不回来”分开计数。
拿在线数据(图片 rkey、cookie、skey 等)的链路只有两步,都在 native 里:① 只读 QQ 进程内存取出该会话的 a2 / d2 / d2key;② native 自己组帧、签名、直连 QQ 服务器收发。发包不再经过「注入 hook + unix socket / named pipe 管道转发」那套(hook 传输层已删),也就没有「等 hook 就绪」这一环。
⚠️ 登录取密钥不再走这条链路(NineBird 已删):装号 / 取密钥走纯协议quickLogin/qrLogin(wtlogin快登或扫码),从login.db取缓存的 a1 + 设备 guid 直连登录服务器换 a2/d2/d2key,再顺带发 OIDB0xcde_2取 dbKey;不读内存、不注入、不提权。这条 attach 链路仍服务于在线实例功能(图片 rkey / 装扮 / 红包 / 抓包等):prepareInstanceAttach(读内存拿 a2/d2/d2key + 登记原生 SSO 会话)→fetchKeyFromInstance(发包 OIDB 0xcde_2 取 dbKey)。登记是必备的一步 ——sendOidbPacket/sendPacket只认已登记的 pid,setSsoSession之前第一包必然报「还没有登记 SSO 会话」。登记用的 uid 从login.db解析、guid 从 QQ 数据根离线算(都无需额外权限)。
读内存这一步仍有权限门槛:Linux 要 root(或 CAP_SYS_PTRACE)且 /proc/sys/kernel/yama/ptrace_scope 放行;macOS 要 root 且目标未开强化运行时保护(QQ 开了,所以得先关 SIP);Windows 要管理员。macOS 上 SIP 开着时读内存无解(task_for_pid 连 root 都拒),因此在线实例功能在此之前不可用 —— 但登录本身已与内存解耦,纯协议即可完成。
ℹ️ Linux 宿主总是先试免密直连:
yama ptrace_scope=0(或CAP_SYS_PTRACE)时同用户 attach 直接成功,不弹窗、不要密码;只有内核真的拒了(EPERM/EACCES)才回到提权。引导弹窗里的「不再提醒」只静音该弹窗,不影响这个顺序——被拒后仍会尝试提权。顺序钉在packages/service/src/bootstrap/attach_flow.ts(有单测)。
⚠️ 例外:root 读不到安装目录的宿主(AppImage —— payload 在没开allow_other的 FUSE 挂载上;以及单用户 FUSE 家目录)跑不了 elevated worker,连exec我们自己的二进制都是 EACCES(env: "…": 权限不够)。这类宿主改为「root 只临时放开 yama ptrace 保护 → 非特权进程自己读 → 写回原值」,见apps/desktop/src/main/attach_elevation.ts的escalateViaPtraceScope。
| JS 函数 | 参数 | 返回 | 说明 |
|---|---|---|---|
scanSessionMaterial(pid) |
pid | Promise<SessionMaterial> |
运行时 RTTI 自举、零硬编码 RVA,读出该会话的 a2 / d2 / d2key(十六进制串;没扫到的项为 undefined,不影响其它项)。失败时 reject,错误信息本身就是提权/版本变化的诊断提示。 |
readDeviceGuid(dataRoot) |
QQ 数据根路径 | string | null |
离线算出设备 guid(32 位小写 hex)。不需要任何权限、不碰进程;算不出返回 null。 |
writeDeviceGuid(dataRoot) |
QQ 数据根路径 | string | null |
随机换一个设备 guid 并写回数据目录,返回新值。三端各改写自己那份来源(Linux machine-info 的 MAC / macOS machineid-info 的 machine_id / Windows Registry20 或 Registry2.0.db)。与 readDeviceGuid 一样同步——只改文件,不 await 任何东西。调用方必须先确认没有 QQ 实例在跑(在线时会被 QQ 覆盖回去);不需要权限。老产物没有这个方法,调用方要按"旧产物"处理。 |
setSsoSession(pid, session, wrapperPath?) |
物料 + wrapper.node 路径 |
Promise<void> |
登记这一场会话。只存不连:不读内存、不发包时一个 socket 都不存在;TCP 等到第一次发包才建,闲置一分钟丢弃,断了下次发包自动重连。 |
hasSsoSession(pid) / clearSsoSession(pid) |
pid | Promise<boolean> |
诊断 / 忘掉物料(账号下线、QQ 重启时调用,会一并关掉可能存在的连接)。 |
SessionMaterial = { a2?: string; d2?: string; d2Key?: string }- 扫描 resolve 不代表物料完整:attach 缓存只保留三项均为有效 hex 且 d2key 为 16 字节的结果,部分结果原样返回、不跨扫描合并。账号 monitor 即使已有未过期的 rkey/clientKey,也会补齐当前 PID 的物料;失败按 30 秒起、最长 5 分钟退避重试。uid/guid 暂缺只检查账号配置及补读离线设备身份,不读内存、不弹密码框;仅用户明确取消授权会暂停本 PID 的自动重试。原生登记完成后仍通过
onlineCredentialsReady通知界面重查实际能力,不把提交密码当作成功。 - 桌面端能力查询同时返回账号身份是否齐全、是否需要重新读内存,以及
hasSsoSession确认的ssoReady。受限模式仅在当前 PID 的有效物料、账号/设备身份和原生登记都就绪后解除;查询不触发内存读取或网络请求,异步查询期间切号会拒绝旧结果。 credentialsPending包括正在读取、等待授权、原生登记和未取消的自动重试,不包括登记完成后的网络取票。界面保留「正在启用完整功能」及常驻进度;提交授权后须等新的状态查询完成,不用提交前缓存的就绪状态提前结束。- 前端授权请求优先于
credentialsPending:存在尚未提交的密码或 ptrace 引导请求时显示「待提权」,标题栏、详情和横幅均暂停进入中进度;最后一项请求处理后才恢复进入中。横幅原地更新,避免退场动画残留相反的阶段提示。 SsoSessionConfig = { uin; a2: Buffer; d2: Buffer; d2Key: Buffer; guid; uid; subAppId; clientConnSeq?; traceParent? }(traceParent只给离线比对用)。⚠️ subAppId必须等于当前运行 QQ 构建的 appid(major.node里QQAppId/537xxxxxx,resolveAppidFromMajor扫出来的那个值);QQ 每次更新 都会换 appid,用错值服务端一律回Reply status error: -10003 身份验证失败。 WeQ 侧见packages/service/src/account/sso_session.ts的resolveSubAppId(不传就动态解析,兜底常量只是应急)。
ℹ️ 刻意不做上线注册与心跳(对照
../LagrangeV2):那是个纯协议框架,机器上只有它一个客户端,所以它必须自己发SsoInfoSync上线、自己心跳。我们不是 —— a2/d2/d2key 就是从同机 QQ 进程内存里读的,那条会话的在线状态与心跳由 QQ 本体维持;再注册一次只会让服务端看到「同设备 guid + 同一份 d2 的第二个客户端」。这里只借凭据发包,其余交给 QQ。
| JS 函数 | 参数 | 返回 | 说明 |
|---|---|---|---|
sendOidbPacket(pid, command, subCommand, body, isUid, needSign?) |
OIDB 命令/子命令/protobuf body/isUid | Promise<Buffer> |
通用 OIDB 发包:任何 OIDB 请求都行,无需改 native。isUid=true 走 UIN-form 变体(reserved=1)。needSign(缺省 true)由调用方按命令逐个标注。 |
sendPacket(pid, cmd, body, needSign?) |
完整 SSO 命令串 + protobuf body | Promise<Buffer> |
通用原始发包,给不是 OIDB 的 trpc 服务用(命令串如 QunAlbum.trpc.qzone.webapp_qun_media.QunMedia.GetMediaList)。needSign 同上。 |
resetPacketSequence() / currentPacketSequence() |
— | number |
全局发包序号。重连 / 换会话时先 resetPacketSequence() 回收起点;currentPacketSequence() 仅供诊断。 |
locateSignFunction(wrapperPath) / signPacket(wrapperPath, cmd, src, seq) |
wrapper.node 路径 + 命令 + 明文 + 序号 |
RVA / SignOutput |
wrapper.node 签名函数的定位与调用:needSign 的命令靠它算 SecToken / SecExtra / SecSign。离线逐字节核对抓包用 buildSsoPacket(req)。 |
业务命令不在 native:clientKey / 下载 rkey / decryptKey / skey / p_skey / bkn 的请求构造 与解析已经在
@weq/protocol+@weq/service(account/online_ticket.ts)里实现,只通过 上面的两个通用接口发包。新增「走后门业务」继续走sendOidbPacket/sendPacket+ TS schema, 不要为此改 native 引入新接口。
⚠️ 唯一的例外是登录流程自己:quickLogin/qrLogin在换到 a2/d2/d2key 的那条连接上 顺带发0xcde_2(dbkey)与0x102a_0(pskeyDomains→p_skey,一域一轮,默认vip.qq.com+qzone.qq.com)。 这两步必须在登录连接里完成(会话物料刚拿到、连接还热),所以留在 native;除此之外的业务命令 一律走上面的通用接口,不要在 login 里继续加。
非侵入式、不注入:直接读网卡 → TCP 重组 → MSF 帧切分 → 用调用方传入的 d2key 做 TEA 解密(native 不再自己扫密钥)。
pcap_open_live + 装 BPF 过滤要几十到几百毫秒,而被抓的应答可能个位数毫秒就回来
(例如 QQ 语音转录:请求 → 立即 ack → 真正的结果稍后由服务端 push 推回)。先发包
再开抓,首包必漏。所以抓包是一条可提前 armed 的长生命周期会话:
startCapture(pid) // 直到网卡已打开、BPF 已装、读循环即将开始才 resolve
→ 调用方发包(setSsoSession / sendOidbPacket / sendPacket …)
→ takeFrames(pid, { waitMs: 10_000 }) // 等到窗口结束,一次性取回全部帧,自己筛
→ stopCapture(pid)
取帧是环形缓冲 + 游标的拉模型:native 侧保证不漏,环形满了会如实把 dropped
报出来;onFrame 回调只是同一份数据的可选推送通道,JS 慢顶多丢投递,不影响
环形缓冲里的真相。会话按 pid 索引,与 setSsoSession 一致;多个 QQ 进程可各抓各的。
| JS 函数 | 参数 | 返回 | 说明 |
|---|---|---|---|
probeCaptureSupport() |
— | CaptureSupport |
抓包后端是否就绪。Windows 检查 Npcap(wpcap.dll);Linux/macOS 后端一定在,能否抓到取决于 elevated(权限)。hint 是缺后端/缺权限时的引导。 |
startCapture(pid, options?, onFrame?) |
pid + 可选 CaptureOptions + 可选回调 |
Promise<CaptureSession> |
开启(或替换)抓包会话。请在发包之前调用 —— 它直到网卡已打开、BPF 已装、读循环即将开始才 resolve。 |
takeFrames(pid, options?) |
pid + 可选 TakeOptions |
Promise<FrameBatch> |
取帧。waitMs > 0 时一直收集到窗口结束再返回。 |
stopCapture(pid) |
pid | Promise<CaptureStats> |
停止并释放,返回统计。 |
桌面应用的 startAccountCapture 另返回应用层 sessionId,UI 和在线转写在轮询、停止时回传它。它不是 nt_helper 原生选项:原生仍按 pid 管理和替换会话。应用层串行化同 pid 的启动/停止,并在轮询前后检查身份,避免旧窗口关闭或迟到结果影响后来建立的会话。旧 IPC 请求可不带此字段,兼容原参数;新调用方应始终使用返回的 ID。会话 ID 仅在运行期间持有,不写入账号配置或设置备份。
类型
CaptureOptions = { d2key?; iface?; port?; ringFrames?; pcapFile? }d2key:32 字符 hex;省略时回退到该 pid 已登记的setSsoSession物料里的 d2key。iface:默认auto(自动选默认路由出口网卡),也可填eth0/en0/Npcap 设备名。port:默认auto(持续按 MSF 帧签名识别,端口中途切换也不漏);也可14000/14000,443,80/auto,443。ringFrames:环形缓冲帧数上限(默认 512)。
CaptureSession = { pid; iface; port; linktype }TakeOptions = { cursor?; waitMs?; minFrames?; maxFrames?; matchCmd? }CapturedFrame = { cursor; ts; direction; proto; encryptType; seq; cmd?; body?: Buffer; plain?: Buffer; raw: Buffer }cmd/body/plain在 TEA 解密 + SSO 头解析成功后才有;raw永远是完整原始帧(含 4 字节长度前缀)。body是解密后的 protobuf 原始字节,展开交给@weq/protocol—— native 不引 protobuf 描述符。
FrameBatch = { frames; nextCursor; dropped }:dropped是累计溢出淘汰帧数;nextCursor下次原样传回来即可续取。CaptureStats = { frames; dropped; packets }CaptureSupport = { available; backend; elevated; hint }
服务端的异步推送落在 startCapture 之后,所以「请求 → 监听 10 秒 → 自己筛」是安全的:
await nt.startCapture(pid, { d2key }); // 先 armed(早于发包)
try {
await nt.sendPacket(pid, 'pttTrans.TransC2CPttReq', body);
const batch = await nt.takeFrames(pid, { waitMs: 10_000 }); // 窗口内全部帧
// batch.frames 里自己按 cmd / body 筛出 TransC2CPttRsp 与 OlPushService.MsgPush
} finally {
await nt.stopCapture(pid);
}- Linux:需要 root 或
CAP_NET_RAW(与 attach 同档);产物静态链接 libpcap,不依赖用户机的libpcap.so.*。 - macOS:需要 root(BPF 设备默认 root 可读)。
- Windows:需要管理员 且 已安装 Npcap;缺失时
probeCaptureSupport()返回available: false+ 安装引导。
Electron 不能以 root 运行,而抓包要 root —— 所以桌面端把「抓包会话」整段放进一个 临时 root 子进程,主进程只做代理:
渲染层点「开始抓包」
→ 主进程(wonderful_tools.captureStart)先登记 SSO 会话(读内存拿 d2key)
→ sudo -S 起 captureWorker.mjs(ELECTRON_RUN_AS_NODE,见 src/main/capture_elevation.ts)
→ 子进程 require nt_helper.node,拿到 {port, token} hello 后走环回套接字协议
→ 主进程把 start / take / stop 原样转给子进程(协议见 src/main/capture_protocol.ts)
要点:
- 密码来自渲染层自绘的密码框(
elev::request-password,macOS 姿势的sudo -S,不走 polkit); 起子进程前先sudo -n true探一次缓存,刚刚读过内存的机器不会再弹第二个框。 - 协议走 127.0.0.1 环回套接字而不是 stdin/stdout:
sudo -S要从 stdin 读密码,复用同一条 管道会被 sudo 的缓冲读写吃掉。hello 里的随机token是门禁(端口只有本机可达)。 - 子进程是另一个 native 实例,主进程
setSsoSession注册的物料它看不见,所以start必须显式带d2key;setcap cap_net_raw,cap_net_admin+eip <binary>也能免掉提权, 但不改系统是默认选择。 - 生命周期:主进程持着环回连接,断开(退出 / 崩溃)时子进程自己停会话再退出;退出时会
主动
dispose。不会把 root 抓包留在后台。
| JS 函数 | 参数 | 返回 | 说明 |
|---|---|---|---|
checkWindowsHelloAvailability() |
— | Promise<{ code: number; available: boolean }> |
查询 Windows Hello 可用性。非 Windows 恒返回 NotSupported(100)。 |
verifyWindowsHello(message, hwnd?) |
提示文案 + 可选 hwnd(预留) |
Promise<{ code: number; success: boolean }> |
Windows Hello 弹窗验证。实现细节勿改:native 在专用 MTA 线程执行 WinRT 异步,避免 Electron STA 主线程死锁。 |
- 先
getInitStatus()再干活:环境校验失败时,check_init!类函数会抛EnvIrreversiblyError/"Environment validation failed";check_init_or_default!类则返回“失败默认值”(如 probe 返回success:false)而非抛错。调用方两种都要处理。 setLogPath尽早调用:每个接口内部都会logger::init_logger(),日志目标取决于当时配置。默认只记 info 及以上事件;高频路径(probeDbLock、closeDb、testDatabaseKey、逐包接收、端口探测)都在 debug 级,排查时用环境变量WEQ_LOG_LEVEL=debug|trace抬升(error/warn/off也可)。loader 侧的逐文件资产校验同理,用WEQ_NATIVE_DEBUG=1打开。- 连接缓存:
executeSql*对同一dbPath缓存连接。登出 / 换号记得closeDb/closeAllDb释放句柄与密钥。 - SQL 只读优先:
executeSql注释明确“SELECT only recommended”;写接口存在且可用,但改动 QQ 运行时数据库前务必先备份。 algo别假设:QQ NT 各库、各客户端版本的 page/KDF HMAC 不固定。未知库一律先testDatabaseKey,得到CipherAlgo再喂给其它函数;不要硬编码SHA1/SHA1。- 在线发包需先登记物料:
sendOidbPacket/sendPacket的前提是「该 pid 已setSsoSession登记过、且物料来自在线 QQ」。 - 业务命令去 TS 找:
clientKey/ rkey / decryptKey / skey / p_skey / bkn 见packages/protocol与packages/service/src/account/online_ticket.ts,不要在 native 层重复实现。 - 不要重复造轮子:上面列的每一项 native 能力都已实现并经过验证。给 WeQ 加功能前,先在本页 /
packages/db/packages/protocol里找现成能力
- 源码:
../nt_helper/src/(Rust / napi-rs),入口lib.rs - 使用示例:
apps/desktop/src/main/attach_worker.ts(加载 + 初始化 + 调用) - 数据库层封装:
packages/db(基于executeSqlWithKey等) - 纯协议登录(装号 / 取密钥):
packages/service/src/bootstrap/key.ts+nt_helper的quickLogin/qrLogin