Files

352 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview 酷狗音乐 API 行为指纹模拟生成器(Node.js 版)
*
* 本模块用于在服务端生成模拟的用户行为指纹数据,替代浏览器端 WASM 的功能。
* 主要用于自动化请求场景(如批量登录、接口调用等),绕过酷狗的行为检测机制。
*
* 核心功能:
* 1. 生成模拟的鼠标移动轨迹(贝塞尔曲线 + 随机抖动)
* 2. 生成模拟的页面交互事件(滚动、窗口 resize 等)
* 3. 使用 AES-128-CBC 加密行为数据得到 EDTEncrypted Data Token
* 4. 使用 RSA-OAEP SHA-256 加密 AES 密钥得到 SIDSession ID
* 5. 服务端用 RSA 私钥解密 SID 得到 AES 密钥,再用 AES 密钥解密 EDT 还原行为数据
*
* 加密方案:
* 明文(行为数据)→ AES-128-CBC 加密 → EDTBase64
* AES 密钥 → RSA-OAEP SHA-256 加密 → SIDBase64
*
* @module generate_simulate
* @requires crypto-js - AES 加密库
* @requires node-forge - RSA 加密库
* @requires ./util - 工具函数(randomString
*/
const { randomString } = require('./util');
const CryptoJS = require('crypto-js');
const forge = require('node-forge');
/**
* RSA 公钥(PEM 格式)
* 从酷狗 WASM 二进制中提取的 SPKI 公钥,用于 RSA-OAEP SHA-256 加密 AES 密钥
* 算法: RSA-2048,公钥指数 65537 (0x10001)
*
* 服务端持有对应的私钥,用于解密 SID 获取 AES 密钥
*/
const publicKey = `-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoW2+Ylo8ALePSQTP0xBF\nlFmEOHvBD9tS+s7DBlfKEu3RzzvZTaX1JtYbX4+AVUqj6ARz8IM+CKByqGFvbHN/\nW64XxNI+q7z36ajCL3VTJ2W5G9MCJitc6oGbire4NQfhaEq0nC+hxBWQvCbIFflA\n2ItrLUbSU7z1bHA/a+jlQm4OWvY+IKnTryOJTPuT1yNOVjbJ8wBLKy2DgQr9pPqW\nPmEQtGpR5IM9V8Kao6PaSdKYOWGbX3i2+RzIKhvZUxxtJwdVbqPlDPlW9h4/xIBc\n56Lgvr4aIl8nFtwbj4UJVUTFuGrs0tY9H/tXvZ22dUCKuGxW/gW7ZF+gXz6vHtYa\nrQIDAQAB\n-----END PUBLIC KEY-----`;
/**
* AES 初始化向量(固定值)
* ASCII 解码为 "kugousecurity123"
* 与浏览器端 WASM 中硬编码的 IV 一致,每次加密都使用相同的 IV
* @type {string}
*/
const iv = 'kugousecurity123';
/**
* 哨兵值(接近 0xFFFFFFFF 的随机值)
* WASM 中每条事件记录后都会跟一条哨兵记录,用于标记事件结束或表示"无数据"
* 每次调用 generateSimulate 时会重新生成,增加指纹随机性
* @type {number}
*/
let SENTINEL = 0xffffffff - Math.floor(Math.random() * 20);
/**
* 生成 EDT 中的 data 字段(用户行为指纹数据)
*
* 模拟真实用户在页面上的交互行为,包括:
* - 窗口加载/resize 事件(type 6
* - 页面滚动事件(type 5)
* - 鼠标移动轨迹(type 3,贝塞尔曲线生成)
*
* 事件编码格式:各条目用冒号 `:` 分隔,每个条目的字段用逗号 `,` 分隔
* - type-3(鼠标移动): "3,时间差,子索引,X,Y"
* - type-5(滚动/计时): "5,时间差,事件索引"
* - type-6(窗口事件): "6,时间差,事件索引,宽,高"
* - 哨兵记录: 时间差字段替换为 SENTINEL 值
*
* @param {Object} opts - 配置项
* @param {number} opts.startX - 鼠标起点 X 坐标
* @param {number} opts.startY - 鼠标起点 Y 坐标
* @param {number} opts.endX - 鼠标终点 X 坐标
* @param {number} opts.endY - 鼠标终点 Y 坐标
* @param {number} opts.mousePoints - 鼠标轨迹采样点数
* @returns {string} 编码后的 data 字段字符串,各事件条目用 `:` 分隔
*/
function generateEDTData(opts) {
const { startX, startY, endX, endY, mousePoints } = opts;
const entries = []; // 所有事件条目
let ts = 0; // 累计时间戳(毫秒),从 0 开始递增
let ei = 0; // 全局事件索引(用于 type-5/6 事件的标识)
// --- 初始化: 两个 type-5 零事件 ---
// 模拟 WASM 启动时记录的初始事件,每个事件后跟一条哨兵记录
entries.push(f5(0, 0));
entries.push(fs5(0));
entries.push(f5(0, 0));
entries.push(fs5(0));
// --- 窗口事件 (type 6) ---
// 模拟窗口加载/resize 事件,窗口尺寸设为 750x500(模拟移动端页面)
ts += ri(5, 20); // 随机延迟 5-20ms(模拟页面加载耗时)
entries.push(f6(ts, ei, 750, 500)); // 窗口事件记录
entries.push(fs6(ei, 750, 500)); // 对应的哨兵记录
ei++;
// --- 滚动事件 (type 5) ---
// 模拟用户滚动页面的行为(3 次滚动,间隔不均匀)
for (let i = 0; i < 3; i++) {
ts += ri(80, 600); // 滚动间隔 80-600ms(模拟不均匀的滚动节奏)
entries.push(f5(ts, ei));
entries.push(fs5(ei));
ei++;
}
// --- 鼠标轨迹 (type 3) ---
// 用三阶贝塞尔曲线生成平滑的鼠标移动路径
const path = bezierPath(startX, startY, endX, endY, mousePoints);
let si = 0; // 子索引(0 或 1,交替变化)
for (let i = 0; i < path.length; i++) {
const { x, y } = path[i];
ts += ri(8, 50); // 鼠标移动间隔 8-50ms
entries.push(f3(ts, si, Math.round(x), Math.round(y)));
entries.push(fs3(si, Math.round(x), Math.round(y)));
// 每隔 12 帧插入一个滚动事件,模拟边滚动边移动鼠标的真实行为
if (i > 0 && i % 12 === 0) {
ts += ri(20, 60);
entries.push(f5(ts, ei));
entries.push(fs5(ei));
ei++;
}
si = (si + 1) % 2; // 子索引在 0 和 1 之间交替
}
// --- 结束事件 ---
// 最后一个鼠标位置,带微小随机偏移(模拟点击前的微调)
ts += ri(5, 30);
entries.push(f3(ts, 1, Math.round(endX + ri(-5, 5)), Math.round(endY + ri(-5, 5))));
entries.push(fs3(1, Math.round(endX), Math.round(endY)));
return entries.join(':');
}
// ============================================================
// 贝塞尔曲线鼠标路径生成
// ============================================================
/**
* 用三阶贝塞尔曲线生成模拟真人的鼠标移动路径
*
* 真人鼠标轨迹特点:
* - 不是直线,有弧度和加速减速
* - 有微小抖动(手抖),起步时抖动大,移动后趋于稳定
* - 起步慢、中间快、结束减速(由贝塞尔曲线的参数 t 均匀采样自然实现)
*
* 三阶贝塞尔公式: B(t) = (1-t)³·P0 + 3(1-t)²t·P1 + 3(1-t)t²·P2 + t³·P3
* 其中 P0=起点, P3=终点, P1/P2=两个随机控制点
*
* @param {number} sx - 起点 X
* @param {number} sy - 起点 Y
* @param {number} ex - 终点 X
* @param {number} ey - 终点 Y
* @param {number} n - 采样点数(越多轨迹越平滑)
* @returns {Array<{x:number, y:number}>} 路径点数组,长度为 n+1
*/
function bezierPath(sx, sy, ex, ey, n) {
// 生成两个随机控制点,使路径不是直线而是有弧度的曲线
// 控制点在起点到终点的连线附近随机偏移
const c1x = sx + (ex - sx) * 0.3 + ri(-80, 80); // 第一个控制点 X(起点 30% 处 + 随机偏移)
const c1y = sy + (ey - sy) * 0.2 + ri(-60, 60); // 第一个控制点 Y(起点 20% 处 + 随机偏移)
const c2x = sx + (ex - sx) * 0.7 + ri(-60, 60); // 第二个控制点 X(起点 70% 处 + 随机偏移)
const c2y = sy + (ey - sy) * 0.8 + ri(-40, 40); // 第二个控制点 Y(起点 80% 处 + 随机偏移)
const pts = [];
for (let i = 0; i <= n; i++) {
const t = i / n; // 参数 t 从 0 到 1,均匀采样
const u = 1 - t; // 1 - t,用于公式简化
// 三阶贝塞尔公式展开计算
const x = u * u * u * sx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex;
const y = u * u * u * sy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey;
// 抖动幅度: 起点大(3px),逐渐减小到 0.5px(模拟起步时手抖,移动后趋于稳定)
const jitter = Math.max(0.5, 3 - t * 2.5);
pts.push({
x: x + (Math.random() - 0.5) * jitter, // X 方向随机抖动
y: y + (Math.random() - 0.5) * jitter, // Y 方向随机抖动
});
}
return pts;
}
// ============================================================
// 事件记录格式化函数
// ============================================================
/**
* 格式化 type-3 事件(鼠标/触摸移动)
* @param {number} t - 时间差(毫秒)
* @param {number} i - 子索引(0 或 1,交替变化)
* @param {number} x - 鼠标 X 坐标
* @param {number} y - 鼠标 Y 坐标
* @returns {string} 格式: "3,时间差,子索引,X,Y"
*/
function f3(t, i, x, y) {
return `3,${t},${i},${x},${y}`;
}
/**
* 格式化 type-5 事件(滚动/计时)
* @param {number} t - 时间差(毫秒)
* @param {number} i - 事件索引
* @returns {string} 格式: "5,时间差,事件索引"
*/
function f5(t, i) {
return `5,${t},${i}`;
}
/**
* 格式化 type-6 事件(窗口事件)
* @param {number} t - 时间差(毫秒)
* @param {number} i - 事件索引
* @param {number} x - 窗口宽度
* @param {number} y - 窗口高度
* @returns {string} 格式: "6,时间差,事件索引,宽,高"
*/
function f6(t, i, x, y) {
return `6,${t},${i},${x},${y}`;
}
/**
* 格式化 type-3 哨兵记录(鼠标事件结束标记)
* 哨兵记录的时间差字段使用 SENTINEL 值,标记该类型事件序列的结束
* @param {number} i - 子索引
* @param {number} x - X 坐标
* @param {number} y - Y 坐标
* @returns {string} 格式: "3,SENTINEL,子索引,X,Y"
*/
function fs3(i, x, y) {
return `3,${SENTINEL},${i},${x},${y}`;
}
/**
* 格式化 type-5 哨兵记录(滚动事件结束标记)
* @param {number} i - 事件索引
* @returns {string} 格式: "5,SENTINEL,事件索引"
*/
function fs5(i) {
return `5,${SENTINEL},${i}`;
}
/**
* 格式化 type-6 哨兵记录(窗口事件结束标记)
* @param {number} i - 事件索引
* @param {number} x - 窗口宽度
* @param {number} y - 窗口高度
* @returns {string} 格式: "6,SENTINEL,事件索引,宽,高"
*/
function fs6(i, x, y) {
return `6,${SENTINEL},${i},${x},${y}`;
}
/**
* 生成 [min, max] 范围内的随机整数(包含两端)
* @param {number} min - 最小值
* @param {number} max - 最大值
* @returns {number} 随机整数
*/
function ri(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// ============================================================
// 核心导出函数
// ============================================================
/**
* 生成模拟的 sid 和 edt 加密数据
*
* 完整流程:
* 1. 生成随机 AES-128 密钥(16 字节,取 MD5 哈希的前 16 字符)
* 2. 随机化鼠标轨迹参数(起点、终点、采样点数)
* 3. 生成模拟行为数据(鼠标轨迹 + 滚动 + 窗口事件)
* 4. 拼接完整明文: mid=xxx;userid=xxx;dfid=xxx;webgl=xxx;webdriver=0;ts=xxx;data=xxx
* 5. AES-128-CBC 加密明文 → EDTBase64
* 6. RSA-OAEP SHA-256 加密 AES 密钥 → SIDBase64
*
* @param {string|number} mid - 设备 MID 标识,不存在时默认 0
* @param {string|number} userid - 用户 ID,不存在时默认 0
* @param {string|number} dfid - 设备指纹 ID(由 register_dev 接口返回),不存在时默认 0
* @param {string} [webglHash] - WebGL 指纹哈希,不传时自动生成
* @returns {{ edt: string, sid: string }} 加密后的数据对象
* - edt: AES-128-CBC 加密后的行为数据(Base64
* - sid: RSA-OAEP 加密后的 AES 密钥(Base64
*/
const generateSimulate = (mid, userid, dfid, webglHash) => {
// 每次调用重新生成哨兵值,增加指纹随机性
SENTINEL = 0xffffffff - Math.floor(Math.random() * 20);
// 生成随机 AES-128 密钥:先生成 16 字节随机字符串,取其 MD5 哈希的前 16 字符
const key = CryptoJS.MD5(randomString(16)).toString(CryptoJS.enc.Hex).substring(0, 16);
// 随机化鼠标轨迹参数,使每次请求的行为指纹不同
const points = ri(30, 60); // 鼠标轨迹采样点数(30~60 个点)
const startX = ri(200, 600); // 鼠标起点 X(页面中部区域)
const startY = ri(200, 500); // 鼠标起点 Y
const endX = ri(500, 700); // 鼠标终点 X(登录按钮附近)
const endY = ri(80, 150); // 鼠标终点 Y
// 参数默认值处理
mid = mid || 0;
userid = userid || 0;
dfid = dfid || 0;
webglHash = webglHash || generateWebGLHash();
const ts = Date.now(); // 当前时间戳
// 生成行为数据(鼠标轨迹 + 滚动 + 窗口事件)
const data = generateEDTData({ startX, startY, endX, endY, mousePoints: points });
// 拼接完整明文
// 格式: mid=xxx;userid=xxx;dfid=xxx;webgl=xxx;webdriver=0;ts=xxx;data=xxx
// - mid: 设备标识
// - userid: 用户 ID
// - dfid: 设备指纹 ID
// - webgl: WebGL 渲染器指纹哈希
// - webdriver: 是否为自动化驱动(0 表示否)
// - ts: 时间戳
// - data: 行为事件数据
const sidPlaintext = `mid=${mid};userid=${userid};dfid=${dfid};webgl=${webglHash};webdriver=0;ts=${ts};data=${data}`;
console.log(sidPlaintext);
// 第1步: AES-128-CBC 加密行为指纹明文 → EDT
// - 密钥: 随机生成的 16 字符字符串
// - IV: 固定值 "kugousecurity123"
// - 填充: PKCS7
// - 输出: Base64 编码的密文
const edtData = CryptoJS.AES.encrypt(sidPlaintext, CryptoJS.enc.Utf8.parse(key), {
iv: CryptoJS.enc.Utf8.parse(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
}).toString();
// 第2步: RSA-OAEP SHA-256 加密 AES 密钥 → SID
// - 使用酷狗服务器的 RSA 公钥加密 AES 密钥
// - 哈希算法: SHA-256
// - MGF1 哈希: SHA-256(与主哈希一致)
// - 输出: Base64 编码的密文
const rsaKey = forge.pki.publicKeyFromPem(publicKey);
const encrypted = rsaKey.encrypt(key, 'RSA-OAEP', {
md: forge.md.sha256.create(), // 主哈希算法
mgf1: { md: forge.md.sha256.create() }, // MGF1 掩码生成函数的哈希算法
});
const ciphertext = forge.util.encode64(encrypted); // Base64 编码
// 返回 EDT(加密的行为数据)和 SID(加密的 AES 密钥)
return { edt: edtData, sid: ciphertext };
};
module.exports = { generateSimulate };