diff --git a/sdk/auth/kg.ts b/sdk/auth/kg.ts index 60a3ddb..a62896e 100644 --- a/sdk/auth/kg.ts +++ b/sdk/auth/kg.ts @@ -1,10 +1,13 @@ -// 酷狗登录 —— token 绑定 / 扫码 / 短信验证码(与 MakcRe/KuGouMusicApi 协议对齐) +// 酷狗登录 —— token 绑定 / 扫码 / 短信验证码 / 账号密码(与 MakcRe/KuGouMusicApi 协议对齐) // 扫码走 login-user.kugou.com 的 web 签名通道;短信走 android 签名 + AES/RSA; +// 账密走 /v9/login_by_pwd,触发 20028 风控时返回验证码挑战(ssa-code 事件 + +// sid/edt 行为指纹,指纹由 kgSimulate.ts 服务端生成,无需浏览器 WASM); // token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定 import crypto from 'node:crypto' import { httpFetch } from '../common/request.ts' import { getCredentials, setCredentials } from './store.ts' +import { generateSimulate } from './kgSimulate.ts' import type { KgCredentials } from './store.ts' const md5 = s => crypto.createHash('md5').update(s).digest('hex') @@ -34,6 +37,82 @@ export const androidSign = (params, data = '') => { return md5(SALT_SIGN + s + data + SALT_SIGN) } +/** RSA PKCS1 v1.5 加密(register_dev 专用;与登录的 RSA 裸填充不同) */ +const rsaEncryptPkcs1Hex = (data: object) => + crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(JSON.stringify(data), 'utf8')).toString('hex') + +// ---- 设备 dfid 正式注册(userservice.kugou.com /risk/v2/r_register_dev)---- +// 随机 dfid 匿名取流易撞 20028 验证;注册后的 dfid 是官方客户端形态,可显著降低风控。 +// 协议(参考 KuGouMusicApi register_dev.js):设备画像 → AES-128-CBC(随机 6 位 key, +// key=md5(key)[0:16]、iv=md5(key)[16:32]) → base64 作 body;p = RSA-PKCS1({aes:key, uid, token})。 +// 响应为原始二进制密文(非 JSON),按 base64 解码后同密钥 AES 解密得 {status, data:{dfid}}。 + +const playlistAesEncrypt = (data: object) => { + const key = randomString(6) + const keyHex = md5(key) + const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(keyHex.slice(0, 16), 'utf8'), Buffer.from(keyHex.slice(16, 32), 'utf8')) + return { + key, + str: Buffer.concat([cipher.update(JSON.stringify(data), 'utf8'), cipher.final()]).toString('base64'), + } +} + +const playlistAesDecrypt = (rawBase64: string, key: string) => { + const keyHex = md5(key) + const d = crypto.createDecipheriv('aes-128-cbc', Buffer.from(keyHex.slice(0, 16), 'utf8'), Buffer.from(keyHex.slice(16, 32), 'utf8')) + const out = Buffer.concat([d.update(Buffer.from(rawBase64, 'base64')), d.final()]).toString('utf8') + try { return JSON.parse(out) } catch { return out } +} + +/** 注册正式 dfid(幂等:已注册直接返回;失败抛错由调用方决定是否容忍) */ +export const registerDfid = async() => { + const dev = getDevice() + if ((dev as any).dfidRegistered) return dev + + const cred = getCredentials('kg') as KgCredentials | null + const userid = Number(cred?.userid || 0) + const deviceMap = { + availableRamSize: 4983533568, availableRomSize: 48114719, availableSDSize: 48114717, + basebandVer: '', batteryLevel: 100, batteryStatus: 3, + brand: 'Redmi', buildSerial: 'unknown', device: 'marble', + imei: dev.guid, imsi: '', manufacturer: 'Xiaomi', uuid: dev.guid, + accelerometer: false, accelerometerValue: '', gravity: false, gravityValue: '', + gyroscope: false, gyroscopeValue: '', light: false, lightValue: '', + magnetic: false, magneticValue: '', orientation: false, orientationValue: '', + pressure: false, pressureValue: '', step_counter: false, step_counterValue: '', + temperature: false, temperatureValue: '', + } + const enc = playlistAesEncrypt(deviceMap) + const p = rsaEncryptPkcs1Hex({ aes: enc.key, uid: userid, token: cred?.token || '' }) + + const params: Record = { + dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER, + clienttime: Math.floor(Date.now() / 1000), + part: 1, platid: 1, p, + } + params.signature = androidSign(params, enc.str) + const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&') + const r = await httpFetch(`https://userservice.kugou.com/risk/v2/r_register_dev?${qs}`, { + method: 'post', + headers: { + dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime), + 'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi', + }, + body: enc.str, + timeout: 15000, + }).promise as any + + // 响应为二进制密文:raw base64 → AES 解密 + const raw = r.raw ? Buffer.from(r.raw).toString('base64') : Buffer.from(JSON.stringify(r.body ?? '')).toString('base64') + const body = playlistAesDecrypt(raw, enc.key) + if (body?.status !== 1 || !body.data?.dfid) { + throw new Error(`dfid 注册失败(status ${body?.status ?? '未知'} ${body?.error ?? ''})`.trim()) + } + const updated = { ...dev, dfid: String(body.data.dfid), dfidRegistered: true } + setCredentials('_kg_device', updated) + return updated +} + /** 设备标识持久化(guid/mid/dfid 稳定复用,登录验证与取流共用同一指纹) */ export const getDevice = () => { const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) } @@ -44,13 +123,14 @@ export const getDevice = () => { return dev } -/** 网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域) */ -const gatewayRequest = async(url, { params: extraParams, data, router, host = 'https://gateway.kugou.com', headers: extraHeaders = {} }: { +/** 网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域);withHeaders 时返回 { body, headers } */ +const gatewayRequest = async(url, { params: extraParams, data, router, host = 'https://gateway.kugou.com', headers: extraHeaders = {}, withHeaders = false }: { params?: Record data?: any router?: string host?: string headers?: Record + withHeaders?: boolean }) => { const dev = getDevice() const params = { @@ -72,6 +152,7 @@ const gatewayRequest = async(url, { params: extraParams, data, router, host = 'h body: data ?? undefined, timeout: 15000, }).promise + if (withHeaders) return { body: r.body ?? {}, headers: (r as any).headers ?? {} } return r.body ?? {} } @@ -334,6 +415,167 @@ export const refreshLogin = async() => { return { refreshed: true } } +// ---- 账号密码登录(/v9/login_by_pwd)+ 20028 验证码挑战 ---- +// 参考 KuGouMusicApi login.js / get_verify_info.js / verify_user_info.js 与 issue #206: +// 密码与验证码走 AES 随机密钥加密(params/pk);风控触发时响应 error_code=20028, +// 事件 id 在响应头 ssa-code(gz_tx_event_xxx);/verifyservice/v3/get_verify_info +// 按 data.url/v_type 区分验证类型(23 腾讯滑块、32 手机验证码、22 活体、34 人脸…); +// 验证结果经 /v4/verify_user_info 提交(verifycode 格式 KGCodeXX|{json} 或短信码), +// sid/edt 为行为指纹(kgSimulate 服务端生成),随后重试登录(短信码经 code 字段随密文上送)。 + +// login_by_pwd 的设备校验常量(与参考实现一致,疑似风控指纹固定值) +const PWD_T1 = '562a6f12a6e803453647d16a08f5f0c2ff7eee692cba2ab74cc4c8ab47fc467561a7c6b586ce7dc46a63613b246737c03a1dc8f8d162d8ce1d2c71893d19f1d4b797685a4c6d3d81341cbde65e488c4829a9b4d42ef2df470eb102979fa5adcdd9b4eecfea8b909ff7599abeb49867640f10c3c70fc444effca9d15db44a9a6c907731e2bb0f22cd9b3536380169995693e5f0e2424e3378097d3813186e3fe96bbe7023808a0981b4e2b6135a76faac' +const PWD_T2 = '31c4daf4cf480169ccea1cb7d4a209295865a9d2b788510301694db229b87807469ea0d41b4d4b9173c2151da7294aeebfc9738df154bbdf11a4e117bb5dff6a3af8ce5ce333e681c1f29a44038f27567d58992eb81283e080778ac77db1400fdf49b7cf7e26be2e5af4da7830cc3be4' +const PWD_T3 = 'MCwwLDAsMCwwLDAsMCwwLDA=' +const PWD_CLIENTVER = 11440 + +// 验证码类型(get_verify_info 的 data.url 前缀 / v_type) +const VERIFY_VTYPES: Record = { + 16: 'QQ 绑定验证', 18: '微信绑定验证', 22: '活体认证', 23: '滑块/图形验证码', 32: '手机短信验证码', 34: '人脸核身', +} +const VERIFY_URL_TYPES: Record = { + KGCodeGT: '极验', KGCodeTX: '腾讯', KGCodeKG: '酷狗旋转', KGCodeKG2: '酷狗滑块', KGCodeSM: '数美', KGCodeYD: '易盾', +} + +export interface VerifyChallenge { + eventid: string + v_type: number + vTypeLabel: string + url?: string + provider?: string + txappid?: string + sessionid?: string + sid: string + edt: string +} + +/** 查询验证事件详情(/verifyservice/v3/get_verify_info) */ +export const getVerifyInfo = async(eventid: string) => { + const body = await gatewayRequest('/verifyservice/v3/get_verify_info', { + data: { + eventid, userid: Number(getCredentials('kg')?.userid ?? 0), platid: 2, rtype: 1, + wasm: 1, i: '', sid: '', edt: '', + }, + }) + if (body.status !== 1 && body.error_code !== 0) { + throw new Error(body.error ?? `查询验证信息失败(code ${body.error_code ?? '未知'})`) + } + return body.data ?? {} +} + +/** 提交验证结果(/v4/verify_user_info,verifycode 为 KGCodeXX|{json} 或短信码) */ +export const verifyUserInfo = async({ eventid, v_type, verifycode, sid, edt }: { + eventid: string + v_type: number + verifycode: string + sid?: string + edt?: string +}) => { + const dev = getDevice() + const userid = Number(getCredentials('kg')?.userid ?? 0) + const fp = sid && edt ? { sid, edt } : generateSimulate(dev.mid, userid, dev.dfid) + const dataMap: Record = { + eventid, userid, platid: 2, v_type: Number(v_type) || 23, wasm: 1, i: '', + sid: fp.sid, edt: fp.edt, + } + if (dataMap.v_type === 32) { + // 手机短信验证码:明文 code + 密文 params 均携带 + const enc = aesEncryptRandom({ code: verifycode }) + Object.assign(dataMap, { + code: verifycode, verifycode, + pk: rsaEncryptHex({ key: enc.key }), params: enc.str, + }) + } else { + // 滑块等图形验证码:KGCodeXX|{ticket,...} 明文上送 + const enc = aesEncryptRandom({}) + Object.assign(dataMap, { + verifycode, + pk: rsaEncryptHex({ key: enc.key }), params: enc.str, + }) + } + const body = await gatewayRequest('/v4/verify_user_info', { + host: 'https://verifyservice.kugou.com', + params: { clientver: 11510 }, + data: dataMap, + }) + if (body.status !== 1) { + const err = new Error(body.error ?? `验证提交失败(code ${body.error_code ?? '未知'})`) + ;(err as any).status = 401 + throw err + } + return { verified: true } +} + +/** + * 账号密码登录(/v9/login_by_pwd) + * 成功返回账号资料;命中 20028 风控返回 VerifyChallenge(先 /login/kg/verify 提交验证, + * 短信码场景再带 code 重试本接口) + */ +export const passwordLogin = async({ username, password, code }: { username: string, password: string, code?: string }) => { + // 账密登录风控最敏感,先确保 dfid 已正式注册(失败容忍,随机 dfid 继续尝试) + await registerDfid().catch(() => {}) + const clienttimeMs = Date.now() + const enc = aesEncryptRandom({ pwd: String(password), code: String(code || ''), clienttime_ms: clienttimeMs }) + const { body, headers } = await gatewayRequest('/v9/login_by_pwd', { + router: 'login.user.kugou.com', + params: { clientver: PWD_CLIENTVER }, + data: { + plat: 1, + support_multi: 1, + clienttime_ms: clienttimeMs, + t1: PWD_T1, + t2: PWD_T2, + t3: PWD_T3, + dev: 'SDY-AN00', + username: String(username), + params: enc.str, + pk: rsaEncryptHex({ clienttime_ms: clienttimeMs, key: enc.key }).toUpperCase(), + }, + headers: { + 'User-Agent': `Android16-1070-${PWD_CLIENTVER}-130-0-LOGIN-wifi`, + 'support-calm': '1', + }, + withHeaders: true, + }) as { body: any, headers: Record } + + if (body.status === 1 && body.data?.secu_params) { + const decrypted = aesDecryptHex(body.data.secu_params, enc.key) + const token = typeof decrypted === 'object' ? decrypted?.token : decrypted + if (!token) throw new Error('登录响应解密失败') + return applyKgLogin({ token, userid: decrypted?.userid ?? body.data?.userid ?? 0 }) + } + + if (body.error_code === 20028) { + const eventid = String(headers?.['ssa-code'] ?? headers?.['SSA-CODE'] ?? body.ssaCode ?? '') + if (!eventid) { + throw new Error('触发风控验证但未返回事件 id(ssa-code),请改用扫码或短信登录') + } + const dev = getDevice() + const userid = Number(getCredentials('kg')?.userid ?? 0) + const { sid, edt } = generateSimulate(dev.mid, userid, dev.dfid) + const info = await getVerifyInfo(eventid).catch(() => ({}) as any) + const vType = Number(info.v_type ?? 23) + const url: string = String(info.url ?? '') + const provider = Object.keys(VERIFY_URL_TYPES).find(k => url.includes(k)) + const challenge: VerifyChallenge = { + eventid, + v_type: vType, + vTypeLabel: VERIFY_VTYPES[vType] ?? `未知类型 ${vType}`, + url: url || undefined, + provider: provider ? VERIFY_URL_TYPES[provider] : undefined, + txappid: info.txappid, + sessionid: info.sessionid, + sid, + edt, + } + return challenge + } + + const err = new Error(body.error ?? `登录失败(code ${body.error_code ?? '未知'})`) + ;(err as any).status = 401 + throw err +} + /** 当前 kg 登录资料(未登录返回 null) */ export const status = () => { const cred = getCredentials('kg') diff --git a/sdk/auth/kgSimulate.ts b/sdk/auth/kgSimulate.ts new file mode 100644 index 0000000..56d517c --- /dev/null +++ b/sdk/auth/kgSimulate.ts @@ -0,0 +1,116 @@ +// 酷狗行为指纹模拟(sid/edt 生成)——移植 MakcRe/KuGouMusicApi util/generate_simulate.js +// 用途:20028 二次验证时替代浏览器 WASM 上报行为数据。 +// 协议:明文 = mid=..;userid=..;dfid=..;webgl=..;webdriver=0;ts=..;data=<事件流> +// edt = AES-128-CBC(明文, key=md5(rand16)[0:16], iv='kugousecurity123') → Base64 +// sid = RSA-OAEP-SHA256(key) → Base64(公钥取自酷狗 WASM 二进制) +// 事件流:贝塞尔曲线鼠标轨迹(type 3) + 滚动(type 5) + 窗口(type 6),条目间以 : 分隔, +// 每条事件后跟一条哨兵记录(时间差字段替换为接近 0xFFFFFFFF 的随机值)。 + +import crypto from 'node:crypto' + +const md5 = (s: string) => crypto.createHash('md5').update(s).digest('hex') + +const SIM_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoW2+Ylo8ALePSQTP0xBF +lFmEOHvBD9tS+s7DBlfKEu3RzzvZTaX1JtYbX4+AVUqj6ARz8IM+CKByqGFvbHN/ +W64XxNI+q7z36ajCL3VTJ2W5G9MCJitc6oGbire4NQfhaEq0nC+hxBWQvCbIFflA +2ItrLUbSU7z1bHA/a+jlQm4OWvY+IKnTryOJTPuT1yNOVjbJ8wBLKy2DgQr9pPqW +PmEQtGpR5IM9V8Kao6PaSdKYOWGbX3i2+RzIKhvZUxxtJwdVbqPlDPlW9h4/xIBc +56Lgvr4aIl8nFtwbj4UJVUTFuGrs0tY9H/tXvZ22dUCKuGxW/gW7ZF+gXz6vHtYa +rQIDAQAB +-----END PUBLIC KEY-----` +const SIM_IV = 'kugousecurity123' + +const ri = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min +const randomString = (n: number) => { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789' + return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('') +} + +let sentinel = 0xffffffff - ri(0, 20) + +const f3 = (t: number | string, i: number, x: number, y: number) => `3,${t},${i},${x},${y}` +const f5 = (t: number | string, i: number) => `5,${t},${i}` +const f6 = (t: number | string, i: number, x: number, y: number) => `6,${t},${i},${x},${y}` + +// 三阶贝塞尔鼠标轨迹:随机控制点 + 起步抖动大、后程收敛 +const bezierPath = (sx: number, sy: number, ex: number, ey: number, n: number) => { + const c1x = sx + (ex - sx) * 0.3 + ri(-80, 80) + const c1y = sy + (ey - sy) * 0.2 + ri(-60, 60) + const c2x = sx + (ex - sx) * 0.7 + ri(-60, 60) + const c2y = sy + (ey - sy) * 0.8 + ri(-40, 40) + const pts: Array<{ x: number, y: number }> = [] + for (let i = 0; i <= n; i++) { + const t = i / n + const u = 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 + const jitter = Math.max(0.5, 3 - t * 2.5) + pts.push({ x: x + (Math.random() - 0.5) * jitter, y: y + (Math.random() - 0.5) * jitter }) + } + return pts +} + +const generateEdtData = ({ startX, startY, endX, endY, mousePoints }: { + startX: number, startY: number, endX: number, endY: number, mousePoints: number +}) => { + const entries: string[] = [] + let ts = 0 + let ei = 0 + + entries.push(f5(0, 0), f5(sentinel, 0), f5(0, 0), f5(sentinel, 0)) + + ts += ri(5, 20) + entries.push(f6(ts, ei, 750, 500), f6(sentinel, ei, 750, 500)) + ei++ + + for (let i = 0; i < 3; i++) { + ts += ri(80, 600) + entries.push(f5(ts, ei), f5(sentinel, ei)) + ei++ + } + + const path = bezierPath(startX, startY, endX, endY, mousePoints) + let si = 0 + for (let i = 0; i < path.length; i++) { + const { x, y } = path[i] + ts += ri(8, 50) + const rx = Math.round(x) + const ry = Math.round(y) + entries.push(f3(ts, si, rx, ry), f3(sentinel, si, rx, ry)) + if (i > 0 && i % 12 === 0) { + ts += ri(20, 60) + entries.push(f5(ts, ei), f5(sentinel, ei)) + ei++ + } + si = (si + 1) % 2 + } + + ts += ri(5, 30) + entries.push(f3(ts, 1, Math.round(endX + ri(-5, 5)), Math.round(endY + ri(-5, 5))), f3(sentinel, 1, Math.round(endX), Math.round(endY))) + + return entries.join(':') +} + +/** 生成模拟行为指纹(sid/edt),验证接口 /v4/verify_user_info 原样上送 */ +export const generateSimulate = (mid: string | number, userid: string | number, dfid: string, webglHash?: string) => { + sentinel = 0xffffffff - ri(0, 20) + const key = md5(randomString(16)).slice(0, 16) + const points = ri(30, 60) + const startX = ri(200, 600) + const startY = ri(200, 500) + const endX = ri(500, 700) + const endY = ri(80, 150) + const webgl = webglHash || md5(randomString(32)) + const plaintext = `mid=${mid || 0};userid=${userid || 0};dfid=${dfid || 0};webgl=${webgl};webdriver=0;ts=${Date.now()};data=${generateEdtData({ startX, startY, endX, endY, mousePoints: points })}` + + const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(key, 'utf8'), Buffer.from(SIM_IV, 'utf8')) + const edt = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]).toString('base64') + const sid = crypto.publicEncrypt( + { key: SIM_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, + Buffer.from(key, 'utf8'), + ).toString('base64') + return { edt, sid } +} + +export default { generateSimulate } diff --git a/sdk/kg/index.ts b/sdk/kg/index.ts index 63b2268..d49af85 100644 --- a/sdk/kg/index.ts +++ b/sdk/kg/index.ts @@ -8,8 +8,13 @@ import hotSearch from './hotSearch.ts' import comment from './comment.ts' import singer from './singer.ts' import album from './album.ts' +import { registerDfid } from '../auth/kg.ts' const kg = { + // 启动时注册正式 dfid(幂等;随机 dfid 匿名取流易撞 20028 验证,注册后显著降低) + async init() { + await registerDfid().catch((err: any) => console.log(`[kg/init] dfid 注册跳过: ${err.message}`)) + }, leaderboard, songList, musicSearch,