diff --git a/sdk/apis/qs.ts b/sdk/apis/qs.ts index 145d062..0043364 100644 --- a/sdk/apis/qs.ts +++ b/sdk/apis/qs.ts @@ -1,8 +1,9 @@ // 汽水音乐 URL 获取(直连) // 两条取流通道: // 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k) -// 登录态: 设备注册 + X-Argus/X-Ladon 自签 → /luna/pc/track_v2 → 加密音频(cenc-aes-ctr) -// → spade key 解密 → /stream/qs/:id 代理端点回明文流(相对地址,路由层负责补全 origin) +// 登录态: 官方 PC 签名 X-Helios/X-Medusa(见 sdk/qs/mssdk.ts)→ /luna/pc/track_v2 +// → 加密音频(cenc-aes-ctr)→ spade key 解密 → /stream/qs/:id 代理端点回明文流 +// (相对地址,路由层负责补全 origin) // 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。 // 登录请求失败自动降级匿名试听。 // 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp } @@ -11,11 +12,9 @@ import { httpFetch } from '../common/request.ts' import { createHash, randomBytes } from 'node:crypto' import musicInfo from '../qs/musicInfo.ts' -import { getDevice } from '../qs/device.ts' import { getCookie, getSessionId } from '../qs/passport.ts' import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from '../qs/mssdk.ts' -import { signRequest } from '../qs/crypto/sign.ts' -import { decryptAudioBuffer, resolveAudioKey } from '../qs/crypto/audiodecrypt.ts' +import { decryptAudioBuffer, resolveAudioKey } from '../qs/audiodecrypt.ts' import type { HttpResponse } from '../types.ts' // 官方直连接口支持的音质:授权曲匿名只有 30 秒试听 128k(登录态取码率最高条目,324k/hi_res 视权益); @@ -76,43 +75,7 @@ const anonymousPreviewUrl = async (trackId: string): Promise => { return url } -// 登录态通道:注册设备 + argus/ladon 自签 → track_v2 → 解密最高码率音频 -const buildSignedUrl = (device: Awaited>): string => { - const params = new URLSearchParams({ - device_platform: 'android', - os: 'android', - ssmix: 'a', - cdid: device.cdid, - channel: device.channel, - aid: '386088', - app_name: 'luna', - version_code: '100198030', - version_name: '19.8.0', - manifest_version_code: '100198030', - update_version_code: '100198030', - resolution: '1080*1920', - dpi: '480', - device_type: device.device_type, - device_brand: device.device_brand, - language: 'zh', - os_api: '35', - os_version: device.os_version, - ac: 'wifi', - device_model: device.device_model, - device_id: device.device_id, - iid: device.install_id, - tz_name: 'Asia/Shanghai', - tz_offset: '28800', - sim_region: 'cn', - package: 'com.luna.music', - luna_apk_type: 'normal_apk', - output_device_type: 'Phone', - openudid: device.openudid, - _rticket: String(Date.now()), - }) - return `${TRACK_V2_URL}?${params.toString()}` -} - +// 登录态通道:官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts) const pickBestEntry = (playInfoList: any) => { const entries = (playInfoList || []).filter(item => item?.MainPlayUrl) if (!entries.length) return null @@ -138,28 +101,9 @@ const fetchTrackPlayer = async (url: string, headers: Record, re const TRACK_V2_BODY = (trackId: string) => JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' }) -// 通道 A(默认):安卓 App 参数 + X-Argus/X-Ladon 自签 -const fetchTrackPlayerByApp = async (trackId: string) => { - const device = await getDevice() - const sessionId = getSessionId() - const url = buildSignedUrl(device) - const requestBody = TRACK_V2_BODY(trackId) - const headers = await signRequest(url, requestBody) - return fetchTrackPlayer(url, { - ...headers, - 'User-Agent': 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80', - 'Content-Type': 'application/json; charset=utf-8', - 'X-Luna-Background-Type': 'foreground', - 'X-Luna-Is-Background-Req': '0', - 'X-Luna-Is-Local-User': '1', - ...(sessionId ? { Cookie: getCookie() || `sessionid=${sessionId}` } : {}), - }, requestBody) -} - -// 通道 B(降级):官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts) const fetchTrackPlayerByPc = async (trackId: string) => { const sessionId = getSessionId() - if (!mssdkAvailable()) throw new Error('PC 签名不可用') + if (!mssdkAvailable()) throw new Error('PC 签名不可用(16689 未探测到签名服务,可配置 QS_SIGN_SERVER 或 data/qs-sign.json,见 qs-signer 仓库)') const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}` const requestBody = TRACK_V2_BODY(trackId) const headers: Record = { @@ -177,18 +121,8 @@ const fetchTrackPlayerByPc = async (trackId: string) => { return fetchTrackPlayer(url, { ...headers, ...signed }, requestBody) } -const resolveTrackPlayer = async (trackId: string) => { - try { - return await fetchTrackPlayerByApp(trackId) - } catch (err: any) { - if (!mssdkAvailable()) throw err - console.log(`[qs/track_v2] 安卓自签通道失败(${err?.message ?? err}),降级官方 PC 签名`) - return fetchTrackPlayerByPc(trackId) - } -} - const resolveSignedStream = async (trackId: string): Promise => { - const trackPlayer = await resolveTrackPlayer(trackId) + const trackPlayer = await fetchTrackPlayerByPc(trackId) const playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList) diff --git a/sdk/auth/store.ts b/sdk/auth/store.ts index defda4f..1f04df1 100644 --- a/sdk/auth/store.ts +++ b/sdk/auth/store.ts @@ -1,5 +1,5 @@ // 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库) -// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_device: {...}, _qs_passport_device: {...} } +// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_passport_device: {...} } // 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换, // 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。 // DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。 diff --git a/sdk/qs/crypto/audiodecrypt.ts b/sdk/qs/audiodecrypt.ts similarity index 100% rename from sdk/qs/crypto/audiodecrypt.ts rename to sdk/qs/audiodecrypt.ts diff --git a/sdk/qs/crypto/argus.ts b/sdk/qs/crypto/argus.ts deleted file mode 100644 index bd2526b..0000000 --- a/sdk/qs/crypto/argus.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { b64encode, getCrypto, getSubtle, hash, pkcs7Pad } from "./index.ts"; -import { ProtobufWriter } from "./protobuf.ts"; -import { simonEncrypt } from "./simon.ts"; -import { sm3 } from "./sm3.ts"; -import type { UnidbgConfig } from "./unidbgconfig.ts"; - -const LOW_RAND = new Uint8Array([0xf2, 0x81]); -const HIGH_RAND = new Uint8Array([0x61, 0x6f]); // 'a', 'o' -const XOR_PREFIX = new Uint8Array([0xf2, 0xf7, 0xfc, 0xff, 0xf2, 0xf7, 0xfc, 0xff]); - -function sm3Prefix6(data: Uint8Array): Uint8Array { - return sm3(data).slice(0, 6); -} - -function decodeStub(xssStub: string): Uint8Array { - const bytes = new Uint8Array(16); - if (xssStub.length >= 32) { - for (let i = 0; i < 16; i++) { - bytes[i] = parseInt(xssStub.slice(i * 2, i * 2 + 2), 16) & 0xff; - } - } - return bytes; -} - -function calculateConstant(code: string): number { - const parts = [...code.replace(/\./g, "").padStart(6, "0")].map(Number); - const weights = [20480, 2048, 20971520, 2097152, 1342177280, 134217728]; - return parts.reduce((sum, part, i) => sum + part * weights[i]!, 0); -} - -function buildProtobuf( - query: string, - xssStub: string, - timestamp: number, - config: UnidbgConfig, -): Uint8Array { - const params = new URLSearchParams(query); - const deviceId = params.get("device_id") ?? ""; - const versionName = params.get("version_name") ?? ""; - const deviceType = params.get("device_type") ?? ""; - const osVersion = params.get("os_version") ?? ""; - const channel = params.get("channel") ?? ""; - - // 空 stub / 空 query 时都对 16 字节全零求 SM3 - const bodyHash = sm3Prefix6(xssStub === "" ? new Uint8Array(16) : decodeStub(xssStub)); - const queryHash = sm3Prefix6( - query === "" ? new Uint8Array(16) : new TextEncoder().encode(query), - ); - - const rand = getCrypto().getRandomValues(new Uint32Array(1))[0]! % 0x7fffffff; - - return new ProtobufWriter() - .varint(1, 0x20200929 * 2) // magic - .varint(2, 2) // version - .varint(3, rand) - .string(4, config.aid) - .string(5, deviceId) - .string(6, config.licenseId) - .string(7, versionName) - .string(8, config.sdkVersion) - .varint(9, config.sdkVersionInt) - .bytes(10, new Uint8Array(8)) // envcode - .varint(11, 0) // platform - .varint(12, timestamp * 2) // createTime - .bytes(13, bodyHash) - .bytes(14, queryHash) - .message(15, (sub) => { - sub.varint(1, 1).varint(2, 1).varint(3, 1).varint(7, 3348294860); - }) - .string(16, config.secDeviceId ?? "") // sec_device_id - .string(20, "none") // pskVersion - .varint(21, config.callType) - .message(23, (sub) => { - sub.string(1, deviceType).string(2, osVersion).string(3, channel).varint(4, calculateConstant(osVersion)); - }) - .varint(25, 2) - .toBytes(); -} - - -export async function getArgus( - query: string, - xssStub: string, - timestamp: number, - config: UnidbgConfig, -): Promise { - const { signKey } = config; - if (signKey.length !== 32) { - throw new Error(`Sign key must be 32 bytes, got ${signKey.length}`); - } - - const protobuf = pkcs7Pad(buildProtobuf(query, xssStub, timestamp, config), 16); - - const sm3Input = new Uint8Array(signKey.length * 2 + LOW_RAND.length + HIGH_RAND.length); - sm3Input.set(signKey, 0); - sm3Input.set(LOW_RAND, signKey.length); - sm3Input.set(HIGH_RAND, signKey.length + LOW_RAND.length); - sm3Input.set(signKey, signKey.length + LOW_RAND.length + HIGH_RAND.length); - - const sm3Output = sm3(sm3Input); - const keyView = new DataView(sm3Output.buffer, sm3Output.byteOffset, sm3Output.byteLength); - const simonKey: [bigint, bigint, bigint, bigint] = [ - keyView.getBigUint64(0, true), - keyView.getBigUint64(8, true), - keyView.getBigUint64(16, true), - keyView.getBigUint64(24, true), - ]; - - const encrypted = new Uint8Array(protobuf.length); - const pbView = new DataView(protobuf.buffer, protobuf.byteOffset, protobuf.byteLength); - const encView = new DataView(encrypted.buffer); - for (let offset = 0; offset < protobuf.length; offset += 16) { - const [low, high] = simonEncrypt( - [pbView.getBigUint64(offset, true), pbView.getBigUint64(offset + 8, true)], - simonKey, - ); - encView.setBigUint64(offset, low, true); - encView.setBigUint64(offset + 8, high, true); - } - - const data = new Uint8Array(XOR_PREFIX.length + encrypted.length); - data.set(XOR_PREFIX); - data.set(encrypted, XOR_PREFIX.length); - for (let i = XOR_PREFIX.length; i < data.length; i++) { - data[i]! ^= data[i % 8]!; - } - data.reverse(); - - const header = new Uint8Array([0xa6, 0x6e, 0xad, 0x9f, 0x77, 0x01, 0xd0, 0x0c, 0x18]); - const plaintext = new Uint8Array(header.length + data.length + HIGH_RAND.length); - plaintext.set(header); - plaintext.set(data, header.length); - plaintext.set(HIGH_RAND, header.length + data.length); - - const subtle = getSubtle(); - const aesKey = await subtle.importKey( - "raw", - await hash.md5bytes(signKey.slice(0, 16)), - { name: "AES-CBC" }, - false, - ["encrypt"], - ); - const iv = await hash.md5bytes(signKey.slice(16)); - const ciphertext = new Uint8Array( - await subtle.encrypt({ name: "AES-CBC", iv }, aesKey, plaintext), - ); - - const result = new Uint8Array(LOW_RAND.length + ciphertext.length); - result.set(LOW_RAND); - result.set(ciphertext, LOW_RAND.length); - return b64encode(result.buffer); -} diff --git a/sdk/qs/crypto/index.ts b/sdk/qs/crypto/index.ts deleted file mode 100644 index 55eb9e2..0000000 --- a/sdk/qs/crypto/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -import md5 from "./md5.ts"; -import { sm3 } from "./sm3.ts"; - -export function getCrypto(): Crypto { - const c = globalThis.crypto as Crypto | undefined; - if (!c?.subtle) { - throw new Error("Crypto API不可用,请检查浏览器版本是否支持该API"); - } - return c; -} - -export function getSubtle(): SubtleCrypto { - return getCrypto().subtle; -} - -export function b64decode(b64: string): ArrayBuffer { - const binaryString = atob(b64); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; -} - -export function b64encode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - const chunkSize = 0x8000; - const chunks: string[] = []; - for (let i = 0; i < bytes.length; i += chunkSize) { - chunks.push( - String.fromCharCode(...bytes.subarray(i, i + chunkSize)), - ); - } - return btoa(chunks.join("")); -} - -export function unhex(hex: string): ArrayBuffer { - if (hex.length % 2 !== 0) { - throw new Error("Invalid hex string"); - } - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) { - const byte = parseInt(hex.slice(i, i + 2), 16); - if (Number.isNaN(byte)) { - throw new Error("Invalid hex string"); - } - bytes[i / 2] = byte; - } - return bytes.buffer; -} - -export function hex(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let hexString = ""; - for (let i = 0; i < bytes.length; i++) { - hexString += bytes[i]!.toString(16).padStart(2, "0"); - } - return hexString; -} - -export function pkcs7Pad(data: Uint8Array, blockSize = 16): Uint8Array { - const padLength = blockSize - (data.length % blockSize); - const padded = new Uint8Array(data.length + padLength); - padded.set(data); - padded.fill(padLength, data.length); - return padded; -} - -export function randomString(length: number): string { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let result = ""; - const array = new Uint8Array(length); - getCrypto().getRandomValues(array); - for (let i = 0; i < length; i++) { - result += chars.charAt(array[i]! % chars.length); - } - return result; -} - -type HashInput = ArrayBuffer | Uint8Array | string; - -function toBytes(input: HashInput): Uint8Array { - if (typeof input === "string") { - return new TextEncoder().encode(input); - } - if (input instanceof Uint8Array) { - return new Uint8Array(input); - } - return new Uint8Array(input); -} - -export const hash = { - sha256: async (input: HashInput): Promise => { - const subtle = getSubtle(); - const digest = await subtle.digest("SHA-256", toBytes(input)); - return hex(digest); - }, - sha256bytes: async (input: HashInput): Promise => { - const subtle = getSubtle(); - return subtle.digest("SHA-256", toBytes(input)); - }, - sha512: async (input: HashInput): Promise => { - const subtle = getSubtle(); - const digest = await subtle.digest("SHA-512", toBytes(input)); - return hex(digest); - }, - sha512bytes: async (input: HashInput): Promise => { - const subtle = getSubtle(); - return subtle.digest("SHA-512", toBytes(input)); - }, - md5: async (input: HashInput): Promise => md5( - typeof input === "string" ? input : toBytes(input).buffer, - ), - md5bytes: async (input: HashInput): Promise => unhex(md5( - typeof input === "string" ? input : toBytes(input).buffer, - )), - sm3: async (input: HashInput): Promise => hex(sm3(toBytes(input)).buffer), - sm3bytes: async (input: HashInput): Promise => sm3(toBytes(input)).buffer, -}; diff --git a/sdk/qs/crypto/ladon.ts b/sdk/qs/crypto/ladon.ts deleted file mode 100644 index a94ee1f..0000000 --- a/sdk/qs/crypto/ladon.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { b64encode, getCrypto } from "./index.ts"; -import { generateLadonKey, speckEncrypt } from "./speck.ts"; -import type { UnidbgConfig } from "./unidbgconfig.ts"; - -export async function getLadon(timestamp: number, config: UnidbgConfig): Promise { - const randomBytes = getCrypto().getRandomValues(new Uint8Array(4)); - - const plaintext = new TextEncoder().encode( - `${timestamp}-${config.licenseId}-${config.aid}`, - ); - const key = await generateLadonKey(randomBytes, config.aid); - const encrypted = speckEncrypt(key, plaintext); - - const result = new Uint8Array(randomBytes.length + encrypted.length); - result.set(randomBytes); - result.set(encrypted, randomBytes.length); - return b64encode(result.buffer); -} diff --git a/sdk/qs/crypto/md5.ts b/sdk/qs/crypto/md5.ts deleted file mode 100644 index db71127..0000000 --- a/sdk/qs/crypto/md5.ts +++ /dev/null @@ -1,161 +0,0 @@ -function rotateLeft(value: number, shiftBits: number): number { - return (value << shiftBits) | (value >>> (32 - shiftBits)); -} - -function addUnsigned(left: number, right: number): number { - return (left + right) >>> 0; -} - -function f(x: number, y: number, z: number): number { - return (x & y) | (~x & z); -} - -function g(x: number, y: number, z: number): number { - return (x & z) | (y & ~z); -} - -function h(x: number, y: number, z: number): number { - return x ^ y ^ z; -} - -function i(x: number, y: number, z: number): number { - return y ^ (x | ~z); -} - -function ff(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number { - return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(f(b, c, d), x), ac)), s), b); -} - -function gg(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number { - return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(g(b, c, d), x), ac)), s), b); -} - -function hh(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number { - return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(h(b, c, d), x), ac)), s), b); -} - -function ii(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number { - return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(i(b, c, d), x), ac)), s), b); -} - -function convertToWordArray(bytes: Uint8Array): number[] { - const wordCount = Math.ceil((bytes.length + 9) / 64) * 16; - const words = new Array(wordCount).fill(0); - - for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) { - const wordIndex = Math.floor(byteIndex / 4); - const bytePosition = (byteIndex % 4) * 8; - words[wordIndex] = words[wordIndex]! | (bytes[byteIndex]! << bytePosition); - } - - const paddingWordIndex = Math.floor(bytes.length / 4); - const paddingBytePosition = (bytes.length % 4) * 8; - words[paddingWordIndex] = words[paddingWordIndex]! | (0x80 << paddingBytePosition); - words[wordCount - 2] = bytes.length << 3; - words[wordCount - 1] = bytes.length >>> 29; - return words; -} - -function wordToHex(value: number): string { - let hex = ""; - for (let byteIndex = 0; byteIndex < 4; byteIndex++) { - const byte = (value >>> (byteIndex * 8)) & 0xff; - hex += byte.toString(16).padStart(2, "0"); - } - return hex; -} - -/** - * 计算 MD5 摘要。 - */ -export default function md5(input: string | ArrayBuffer): string { - const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input); - const words = convertToWordArray(bytes); - - let a = 0x67452301; - let b = 0xefcdab89; - let c = 0x98badcfe; - let d = 0x10325476; - - for (let k = 0; k < words.length; k += 16) { - const aa = a; - const bb = b; - const cc = c; - const dd = d; - - a = ff(a, b, c, d, words[k]!, 7, 0xd76aa478); - d = ff(d, a, b, c, words[k + 1]!, 12, 0xe8c7b756); - c = ff(c, d, a, b, words[k + 2]!, 17, 0x242070db); - b = ff(b, c, d, a, words[k + 3]!, 22, 0xc1bdceee); - a = ff(a, b, c, d, words[k + 4]!, 7, 0xf57c0faf); - d = ff(d, a, b, c, words[k + 5]!, 12, 0x4787c62a); - c = ff(c, d, a, b, words[k + 6]!, 17, 0xa8304613); - b = ff(b, c, d, a, words[k + 7]!, 22, 0xfd469501); - a = ff(a, b, c, d, words[k + 8]!, 7, 0x698098d8); - d = ff(d, a, b, c, words[k + 9]!, 12, 0x8b44f7af); - c = ff(c, d, a, b, words[k + 10]!, 17, 0xffff5bb1); - b = ff(b, c, d, a, words[k + 11]!, 22, 0x895cd7be); - a = ff(a, b, c, d, words[k + 12]!, 7, 0x6b901122); - d = ff(d, a, b, c, words[k + 13]!, 12, 0xfd987193); - c = ff(c, d, a, b, words[k + 14]!, 17, 0xa679438e); - b = ff(b, c, d, a, words[k + 15]!, 22, 0x49b40821); - - a = gg(a, b, c, d, words[k + 1]!, 5, 0xf61e2562); - d = gg(d, a, b, c, words[k + 6]!, 9, 0xc040b340); - c = gg(c, d, a, b, words[k + 11]!, 14, 0x265e5a51); - b = gg(b, c, d, a, words[k]!, 20, 0xe9b6c7aa); - a = gg(a, b, c, d, words[k + 5]!, 5, 0xd62f105d); - d = gg(d, a, b, c, words[k + 10]!, 9, 0x02441453); - c = gg(c, d, a, b, words[k + 15]!, 14, 0xd8a1e681); - b = gg(b, c, d, a, words[k + 4]!, 20, 0xe7d3fbc8); - a = gg(a, b, c, d, words[k + 9]!, 5, 0x21e1cde6); - d = gg(d, a, b, c, words[k + 14]!, 9, 0xc33707d6); - c = gg(c, d, a, b, words[k + 3]!, 14, 0xf4d50d87); - b = gg(b, c, d, a, words[k + 8]!, 20, 0x455a14ed); - a = gg(a, b, c, d, words[k + 13]!, 5, 0xa9e3e905); - d = gg(d, a, b, c, words[k + 2]!, 9, 0xfcefa3f8); - c = gg(c, d, a, b, words[k + 7]!, 14, 0x676f02d9); - b = gg(b, c, d, a, words[k + 12]!, 20, 0x8d2a4c8a); - - a = hh(a, b, c, d, words[k + 5]!, 4, 0xfffa3942); - d = hh(d, a, b, c, words[k + 8]!, 11, 0x8771f681); - c = hh(c, d, a, b, words[k + 11]!, 16, 0x6d9d6122); - b = hh(b, c, d, a, words[k + 14]!, 23, 0xfde5380c); - a = hh(a, b, c, d, words[k + 1]!, 4, 0xa4beea44); - d = hh(d, a, b, c, words[k + 4]!, 11, 0x4bdecfa9); - c = hh(c, d, a, b, words[k + 7]!, 16, 0xf6bb4b60); - b = hh(b, c, d, a, words[k + 10]!, 23, 0xbebfbc70); - a = hh(a, b, c, d, words[k + 13]!, 4, 0x289b7ec6); - d = hh(d, a, b, c, words[k]!, 11, 0xeaa127fa); - c = hh(c, d, a, b, words[k + 3]!, 16, 0xd4ef3085); - b = hh(b, c, d, a, words[k + 6]!, 23, 0x04881d05); - a = hh(a, b, c, d, words[k + 9]!, 4, 0xd9d4d039); - d = hh(d, a, b, c, words[k + 12]!, 11, 0xe6db99e5); - c = hh(c, d, a, b, words[k + 15]!, 16, 0x1fa27cf8); - b = hh(b, c, d, a, words[k + 2]!, 23, 0xc4ac5665); - - a = ii(a, b, c, d, words[k]!, 6, 0xf4292244); - d = ii(d, a, b, c, words[k + 7]!, 10, 0x432aff97); - c = ii(c, d, a, b, words[k + 14]!, 15, 0xab9423a7); - b = ii(b, c, d, a, words[k + 5]!, 21, 0xfc93a039); - a = ii(a, b, c, d, words[k + 12]!, 6, 0x655b59c3); - d = ii(d, a, b, c, words[k + 3]!, 10, 0x8f0ccc92); - c = ii(c, d, a, b, words[k + 10]!, 15, 0xffeff47d); - b = ii(b, c, d, a, words[k + 1]!, 21, 0x85845dd1); - a = ii(a, b, c, d, words[k + 8]!, 6, 0x6fa87e4f); - d = ii(d, a, b, c, words[k + 15]!, 10, 0xfe2ce6e0); - c = ii(c, d, a, b, words[k + 6]!, 15, 0xa3014314); - b = ii(b, c, d, a, words[k + 13]!, 21, 0x4e0811a1); - a = ii(a, b, c, d, words[k + 4]!, 6, 0xf7537e82); - d = ii(d, a, b, c, words[k + 11]!, 10, 0xbd3af235); - c = ii(c, d, a, b, words[k + 2]!, 15, 0x2ad7d2bb); - b = ii(b, c, d, a, words[k + 9]!, 21, 0xeb86d391); - - a = addUnsigned(a, aa); - b = addUnsigned(b, bb); - c = addUnsigned(c, cc); - d = addUnsigned(d, dd); - } - - return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase(); -} diff --git a/sdk/qs/crypto/protobuf.ts b/sdk/qs/crypto/protobuf.ts deleted file mode 100644 index d59176a..0000000 --- a/sdk/qs/crypto/protobuf.ts +++ /dev/null @@ -1,54 +0,0 @@ -const WIRE_VARINT = 0; -const WIRE_BYTES = 2; -const WIRE_FIXED32 = 5; - -export class ProtobufWriter { - private readonly buf: number[] = []; - - private writeVarint(value: number): this { - let v = value >>> 0; - while (v >= 0x80) { - this.buf.push((v & 0x7f) | 0x80); - v >>>= 7; - } - this.buf.push(v & 0x7f); - return this; - } - - private writeKey(fieldNumber: number, wireType: number): this { - return this.writeVarint((fieldNumber << 3) | wireType); - } - - varint(fieldNumber: number, value: number): this { - return this.writeKey(fieldNumber, WIRE_VARINT).writeVarint(value); - } - - fixed32(fieldNumber: number, value: number): this { - this.writeKey(fieldNumber, WIRE_FIXED32); - const v = value >>> 0; - this.buf.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff); - return this; - } - - bytes(fieldNumber: number, data: ArrayLike): this { - this.writeKey(fieldNumber, WIRE_BYTES).writeVarint(data.length); - for (let i = 0; i < data.length; i++) { - this.buf.push(data[i]! & 0xff); - } - return this; - } - - string(fieldNumber: number, value: string): this { - return this.bytes(fieldNumber, new TextEncoder().encode(value)); - } - - message(fieldNumber: number, build: (writer: ProtobufWriter) => void): this { - const sub = new ProtobufWriter(); - build(sub); - return this.bytes(fieldNumber, sub.toBytes()); - } - - toBytes(): Uint8Array { - return Uint8Array.from(this.buf); - } -} diff --git a/sdk/qs/crypto/sign.ts b/sdk/qs/crypto/sign.ts deleted file mode 100644 index 14e3617..0000000 --- a/sdk/qs/crypto/sign.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { hash } from "./index.ts"; -import { getArgus } from "./argus.ts"; -import { getLadon } from "./ladon.ts"; -import { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig.ts"; - -export interface UnidbgHeaders extends Record { - "x-argus": string; - "x-ladon": string; - "x-khronos": string; -} - -export default async function generateHeaders( - rawQuery: string, - xssStub = "", - timestamp: number = Math.floor(Date.now() / 1000), - config: UnidbgConfig = defaultUnidbgConfig, -): Promise { - const [argus, ladon] = await Promise.all([ - getArgus(rawQuery, xssStub, timestamp, config), - getLadon(timestamp, config), - ]); - - return { - "x-argus": argus, - "x-ladon": ladon, - "x-khronos": String(timestamp), - }; -} - -export async function signRequest( - url: string, - body?: ArrayBuffer | Uint8Array | string, - config: UnidbgConfig = defaultUnidbgConfig, -): Promise> { - const rawQuery = new URL(url).search.replace(/^\?/, ""); - - const hasBody = typeof body === "string" ? body.length > 0 : (body?.byteLength ?? 0) > 0; - const xssStub = hasBody ? await hash.md5(body!) : ""; - - const now = Date.now(); - const headers: Record = await generateHeaders( - rawQuery, - xssStub, - Math.floor(now / 1000), - config, - ); - headers["x-ss-req-ticket"] = String(now); - if (hasBody) { - headers["X-SS-STUB"] = xssStub; - } - return headers; -} - -export { getArgus } from "./argus.ts"; -export { getLadon } from "./ladon.ts"; -export { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig.ts"; diff --git a/sdk/qs/crypto/simon.ts b/sdk/qs/crypto/simon.ts deleted file mode 100644 index e38fbc4..0000000 --- a/sdk/qs/crypto/simon.ts +++ /dev/null @@ -1,47 +0,0 @@ -const ROUNDS = 72; -const MASK64 = 0xffffffffffffffffn; - -const Z4 = 0x3dc94c3a046d678bn; - -function getBit(value: bigint, position: number): bigint { - return (value >> BigInt(position)) & 1n; -} - -function rotateLeft64(v: bigint, n: bigint): bigint { - return ((v << n) | (v >> (64n - n))) & MASK64; -} - -function rotateRight64(v: bigint, n: bigint): bigint { - return ((v << (64n - n)) | (v >> n)) & MASK64; -} - -function keyExpansion(key: readonly [bigint, bigint, bigint, bigint]): bigint[] { - const k: bigint[] = [key[0] & MASK64, key[1] & MASK64, key[2] & MASK64, key[3] & MASK64]; - - for (let i = 4; i < ROUNDS; i++) { - let tmp = rotateRight64(k[i - 1]!, 3n); - tmp ^= k[i - 3]!; - tmp ^= rotateRight64(tmp, 1n); - k.push((~k[i - 4]! ^ tmp ^ getBit(Z4, (i - 4) % 62) ^ 3n) & MASK64); - } - return k; -} - -export function simonEncrypt( - plaintext: readonly [bigint, bigint], - key: readonly [bigint, bigint, bigint, bigint], -): [bigint, bigint] { - const k = keyExpansion(key); - - let x = plaintext[0] & MASK64; - let y = plaintext[1] & MASK64; - - for (let i = 0; i < ROUNDS; i++) { - const tmp = y; - const f = rotateLeft64(y, 1n) & rotateLeft64(y, 8n); - y = (x ^ f ^ rotateLeft64(y, 2n) ^ k[i]!) & MASK64; - x = tmp; - } - - return [x, y]; -} diff --git a/sdk/qs/crypto/sm3.ts b/sdk/qs/crypto/sm3.ts deleted file mode 100644 index b713f63..0000000 --- a/sdk/qs/crypto/sm3.ts +++ /dev/null @@ -1,158 +0,0 @@ -// Thanks to https://github.com/JuneAndGreen/sm-crypto/blob/master/src/sm2/sm3.js - -type Bytes = ArrayLike; - -/** - * 循环左移 - */ -function rotl(value: number, shift: number): number { - const normalizedShift = shift & 31; - return (value << normalizedShift) | (value >>> (32 - normalizedShift)); -} - -/** - * 二进制异或运算 - */ -function xor(left: Bytes, right: Bytes): Uint8Array { - if (left.length !== right.length) { - throw new Error("XOR inputs must have the same length"); - } - - const result = new Uint8Array(left.length); - for (let i = 0; i < left.length; i++) { - result[i] = (left[i]! ^ right[i]!) & 0xff; - } - return result; -} - -/** - * 压缩函数中的置换函数 P0(X) = X xor (X <<< 9) xor (X <<< 17) - */ -function p0(value: number): number { - return (value ^ rotl(value, 9)) ^ rotl(value, 17); -} - -/** - * 消息扩展中的置换函数 P1(X) = X xor (X <<< 15) xor (X <<< 23) - */ -function p1(value: number): number { - return (value ^ rotl(value, 15)) ^ rotl(value, 23); -} - -/** - * 计算 SM3 摘要。 - */ -export function sm3(input: Bytes): Uint8Array { - const inputLength = input.length; - const paddedLength = Math.ceil((inputLength + 9) / 64) * 64; - const message = new Uint8Array(paddedLength); - message.set(input); - message[inputLength] = 0x80; - - const bitLength = BigInt(inputLength) * 8n; - for (let i = 0; i < 8; i++) { - message[paddedLength - 1 - i] = Number((bitLength >> BigInt(i * 8)) & 0xffn); - } - - const dataView = new DataView(message.buffer); - const state = new Uint32Array([ - 0x7380166f, - 0x4914b2b9, - 0x172442d7, - 0xda8a0600, - 0xa96f30bc, - 0x163138aa, - 0xe38dee4d, - 0xb0fb0e4e, - ]); - const words = new Uint32Array(68); - const expandedWords = new Uint32Array(64); - - for (let block = 0; block < message.length / 64; block++) { - const start = block * 64; - for (let i = 0; i < 16; i++) { - words[i] = dataView.getUint32(start + i * 4, false); - } - - for (let i = 16; i < 68; i++) { - words[i] = (p1((words[i - 16]! ^ words[i - 9]!) ^ rotl(words[i - 3]!, 15)) - ^ rotl(words[i - 13]!, 7)) - ^ words[i - 6]!; - } - - for (let i = 0; i < 64; i++) { - expandedWords[i] = words[i]! ^ words[i + 4]!; - } - - let a = state[0]!; - let b = state[1]!; - let c = state[2]!; - let d = state[3]!; - let e = state[4]!; - let f = state[5]!; - let g = state[6]!; - let h = state[7]!; - - for (let i = 0; i < 64; i++) { - const t = i <= 15 ? 0x79cc4519 : 0x7a879d8a; - const ss1 = rotl(rotl(a, 12) + e + rotl(t, i), 7); - const ss2 = ss1 ^ rotl(a, 12); - const tt1 = (i <= 15 ? (a ^ b) ^ c : ((a & b) | (a & c)) | (b & c)) - + d + ss2 + expandedWords[i]!; - const tt2 = (i <= 15 ? (e ^ f) ^ g : (e & f) | (~e & g)) - + h + ss1 + words[i]!; - - d = c; - c = rotl(b, 9); - b = a; - a = tt1; - h = g; - g = rotl(f, 19); - f = e; - e = p0(tt2); - } - - state[0] = state[0]! ^ a; - state[1] = state[1]! ^ b; - state[2] = state[2]! ^ c; - state[3] = state[3]! ^ d; - state[4] = state[4]! ^ e; - state[5] = state[5]! ^ f; - state[6] = state[6]! ^ g; - state[7] = state[7]! ^ h; - } - - const result = new Uint8Array(32); - for (let i = 0; i < state.length; i++) { - const word = state[i]!; - result[i * 4] = word >>> 24; - result[i * 4 + 1] = word >>> 16; - result[i * 4 + 2] = word >>> 8; - result[i * 4 + 3] = word; - } - return result; -} - -/** - * 计算 HMAC-SM3。 - */ -export function hmac(input: Bytes, key: Bytes): Uint8Array { - const blockLength = 64; - const normalizedKey = key.length > blockLength ? sm3(key) : Uint8Array.from(key); - const paddedKey = new Uint8Array(blockLength); - paddedKey.set(normalizedKey); - - const innerPad = new Uint8Array(blockLength).fill(0x36); - const outerPad = new Uint8Array(blockLength).fill(0x5c); - const innerKey = xor(paddedKey, innerPad); - const outerKey = xor(paddedKey, outerPad); - - const innerMessage = new Uint8Array(blockLength + input.length); - innerMessage.set(innerKey); - innerMessage.set(input, blockLength); - - const outerMessage = new Uint8Array(blockLength + 32); - outerMessage.set(outerKey); - outerMessage.set(sm3(innerMessage), blockLength); - return sm3(outerMessage); -} diff --git a/sdk/qs/crypto/speck.ts b/sdk/qs/crypto/speck.ts deleted file mode 100644 index d3efe62..0000000 --- a/sdk/qs/crypto/speck.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { hash, pkcs7Pad } from "./index.ts"; - -const ROUNDS = 34; -const MASK64 = 0xffffffffffffffffn; -const WORD_SIZE = 64n; -const ALPHA = 8n; -const BETA = 3n; - -function readUint64LE(view: DataView, offset: number): bigint { - return view.getBigUint64(offset, true); -} - -function keySchedule(key: Uint8Array): bigint[] { - const view = new DataView(key.buffer, key.byteOffset, key.byteLength); - - const ks: bigint[] = [readUint64LE(view, 0) & MASK64]; - const numWords = (key.length * 8) / Number(WORD_SIZE); - const ls: bigint[] = []; - for (let i = 1; i < numWords; i++) { - ls.push(readUint64LE(view, i * 8) & MASK64); - } - - for (let x = 0; x < ROUNDS - 1; x++) { - const rsX = (((ls[x]! << (WORD_SIZE - ALPHA)) + (ls[x]! >> ALPHA)) & MASK64); - const addSxy = (rsX + ks[x]!) & MASK64; - const newX = BigInt(x) ^ addSxy; - const lsY = (((ks[x]! >> (WORD_SIZE - BETA)) + (ks[x]! << BETA)) & MASK64); - ls.push(newX); - ks.push(newX ^ lsY); - } - - return ks; -} - - -function encryptBlock(ks: readonly bigint[], block: Uint8Array, out: Uint8Array, outOffset: number): void { - const view = new DataView(block.buffer, block.byteOffset, block.byteLength); - let y = readUint64LE(view, 0); - let x = readUint64LE(view, 8); - - for (const k of ks) { - const rsX = (((x << (WORD_SIZE - ALPHA)) + (x >> ALPHA)) & MASK64); - const addSxy = (rsX + y) & MASK64; - x = k ^ addSxy; - const lsY = (((y >> (WORD_SIZE - BETA)) + (y << BETA)) & MASK64); - y = x ^ lsY; - } - - const outView = new DataView(out.buffer, out.byteOffset, out.byteLength); - outView.setBigUint64(outOffset, y & MASK64, true); - outView.setBigUint64(outOffset + 8, x & MASK64, true); -} - - -export function speckEncrypt(key: Uint8Array, plaintext: Uint8Array): Uint8Array { - if (key.length !== 32) { - throw new Error(`Speck key must be 32 bytes, got ${key.length}`); - } - - const padded = pkcs7Pad(plaintext, 16); - const ks = keySchedule(key); - - const out = new Uint8Array(padded.length); - for (let i = 0; i < padded.length; i += 16) { - encryptBlock(ks, padded.subarray(i, i + 16), out, i); - } - return out; -} - - -export async function generateLadonKey(randomBytes: Uint8Array, aid: string): Promise> { - const aidBytes = new TextEncoder().encode(aid); - const input = new Uint8Array(randomBytes.length + aidBytes.length); - input.set(randomBytes); - input.set(aidBytes, randomBytes.length); - - const hex = await hash.md5(input); - return new TextEncoder().encode(hex); -} diff --git a/sdk/qs/crypto/ttencrypt.ts b/sdk/qs/crypto/ttencrypt.ts deleted file mode 100644 index e200986..0000000 --- a/sdk/qs/crypto/ttencrypt.ts +++ /dev/null @@ -1,26 +0,0 @@ -// TT 加密(设备注册请求体):gzip + SHA512 校验 + AES-128-CBC,magic 头 "tc" -// 移植自 fanqie-assistant src/crypto/ttencrypt.ts(Node crypto 实现) -import { createCipheriv, createHash, randomBytes } from 'node:crypto' -import { gzipSync } from 'node:zlib' - -// 固定盐(base64 常量,社区共知) -const FIXED_STRING = Buffer.from( - 'TdTC5rgxYgkOUrPHpnM7pByyRiuCmrWKGWs521cXdST0m69/COjWjSanLjfBqVovHwWlGJKu8pSXMrYqOKrdWA==', - 'base64', -) - -const sha512 = (data: Buffer): Buffer => createHash('sha512').update(data).digest() - -export const encryptTT = (data: Buffer): Buffer => { - const rand = randomBytes(32) - const hashValue = sha512(Buffer.concat([sha512(rand), FIXED_STRING])) - const key = hashValue.subarray(0, 16) - const iv = hashValue.subarray(16, 32) - const compressed = gzipSync(data) - const payload = Buffer.concat([sha512(compressed), compressed]) - const cipher = createCipheriv('aes-128-cbc', key, iv) - const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]) - return Buffer.concat([Buffer.from([116, 99, 5, 16, 0, 0]), rand, encrypted]) -} - -export default { encryptTT } diff --git a/sdk/qs/crypto/unidbgconfig.ts b/sdk/qs/crypto/unidbgconfig.ts deleted file mode 100644 index 103af01..0000000 --- a/sdk/qs/crypto/unidbgconfig.ts +++ /dev/null @@ -1,27 +0,0 @@ -// 汽水音乐(luna, aid=386088)签名配置 -// signKey/licenseId 是字节 metasec SDK 家族(v04.04.05-ov-android)共用密钥——经 ssovit(TikTok -// aid=1233)/fanqie-assistant(番茄 aid=1967)/本项目(汽水 aid=386088) 三方同源实证;per-app 的只有 -// 载荷里的 msAppID(argus protobuf field 4,由 config.aid 提供)。metasec license blob(native -// SDK 运行时 init 用)不参与离线签名。 -import { unhex } from "./index.ts"; - -export interface UnidbgConfig { - signKey: Uint8Array; - aid: string; - licenseId: string; - sdkVersion: string; - sdkVersionInt: number; - callType: number; - secDeviceId?: string; -} - -export const defaultUnidbgConfig: UnidbgConfig = { - signKey: new Uint8Array( - unhex("ac1adaae95a7af94a5114ab3b3a97dd80050aa0a39314c40528caec95256c28c"), - ), - aid: "386088", - licenseId: "1611921764", - sdkVersion: "v04.04.05-ov-android", - sdkVersionInt: 134744640, - callType: 738, -}; diff --git a/sdk/qs/device.ts b/sdk/qs/device.ts deleted file mode 100644 index aa4ee5e..0000000 --- a/sdk/qs/device.ts +++ /dev/null @@ -1,171 +0,0 @@ -// 汽水音乐设备注册 —— i.snssdk.com 统一设备服务(按 body 里的 aid 分 app) -// 接口: POST https://i.snssdk.com/service/2/device_register/?tt_data=a(TT 加密 body,免签名) -// 实测(2026-10-01)aid=386088 可注册成功,返回 device_id/install_id/device_token。 -// 设备身份持久化于 data/auth.json 的 _qs_device 字段(复用注册=同一台"设备",避免重复注册) -import { httpFetch } from '../common/request.ts' -import { getCredentials, setCredentials } from '../auth/store.ts' -import { encryptTT } from './crypto/ttencrypt.ts' - -const REGISTER_URL = 'https://i.snssdk.com/service/2/device_register/?tt_data=a' - -// 与签名查询参数一致的设备画像(注册后不再变化) -const DEVICE_FINGERPRINT = { - device_type: 'ABR-AL80', - device_brand: 'HUAWEI', - device_model: 'ABR-AL80', - os_version: '15', - os_api: 35, - resolution: '1080*1920', - dpi: 480, - channel: 'xiaomi_8478_64', -} - -const ANDROID_VERSIONS = ['10', '11', '12', '13', '14'] -const HEX_LOW = '0123456789abcdef' -const pick = (list: readonly string[]) => list[Math.floor(Math.random() * list.length)] -const randomHex = (k: number) => Array.from({ length: k }, () => HEX_LOW[Math.floor(Math.random() * 16)]).join('') -const uuid = () => crypto.randomUUID() - -export interface QsDevice { - device_id: string - install_id: string - device_token?: string - device_type: string - device_brand: string - device_model: string - os_version: string - channel: string - openudid: string - cdid: string - registeredAt?: string -} - -const buildRequestBody = () => { - const osVersion = pick(ANDROID_VERSIONS) - const genTime = Date.now() - return { - magic_tag: 'ss_app_log', - header: { - display_name: '汽水音乐', - aid: 386088, - channel: DEVICE_FINGERPRINT.channel, - package: 'com.luna.music', - sdk_version: '3.7.0-rc.25-luna-music', - sdk_target_version: 29, - git_hash: '711d1a7', - density_dpi: DEVICE_FINGERPRINT.dpi, - display_density: 'xhdpi', - resolution: DEVICE_FINGERPRINT.resolution, - language: 'zh', - timezone: 8, - access: 'wifi', - not_request_sender: 0, - carrier: 'CHINA MOBILE', - mcc_mnc: '46000', - region: 'CN', - tz_name: 'Asia/Shanghai', - tz_offset: 28800, - sim_region: 'cn', - sim_serial_number: [], - oaid_may_support: false, - device_platform: 'android', - custom: { host_bit: 32 }, - pre_installed_channel: '', - is_system_app: 0, - sdk_flavor: 'china', - guest_mode: 0, - os: 'Android', - os_version: osVersion, - os_api: DEVICE_FINGERPRINT.os_api, - device_model: DEVICE_FINGERPRINT.device_model, - device_brand: DEVICE_FINGERPRINT.device_brand, - device_manufacturer: DEVICE_FINGERPRINT.device_brand, - cpu_abi: 'arm64-v8a', - release_build: randomHex(7), - cdid: uuid(), - sig_hash: 'a4a27c2633195374c15651ffc3c4a497', - openudid: randomHex(20), - clientudid: uuid(), - req_id: uuid(), - rom: randomHex(14).toUpperCase(), - apk_first_install_time: genTime - Math.floor(Math.random() * 365) * 86_400_000, - ipv6_list: [ - { type: 'client_anpi', value: '::1' }, - { type: 'client_anpi', value: 'fd9d:8e11:8c31:2a2c:0:0:0:1' }, - ], - }, - _gen_time: genTime, - } -} - -/** 注册新设备并持久化 */ -export const registerDevice = async (): Promise => { - const body = buildRequestBody() - const encrypted = encryptTT(Buffer.from(JSON.stringify(body))) - const request = httpFetch(REGISTER_URL, { - method: 'post', - headers: { - 'User-Agent': 'okhttp/4.10.0', - 'Content-Type': 'application/octet-stream; tt-data=a', - }, - body: encrypted, - timeout: 15000, - }) - const { statusCode, body: resBody } = await request.promise - const data = resBody as any - if (statusCode != 200 || !data?.device_id_str || !data?.install_id_str) { - throw new Error(`设备注册失败: HTTP ${statusCode} ${JSON.stringify(data).slice(0, 120)}`) - } - const device: QsDevice = { - device_id: String(data.device_id_str), - install_id: String(data.install_id_str), - device_token: data.device_token ? String(data.device_token) : '', - device_type: DEVICE_FINGERPRINT.device_type, - device_brand: DEVICE_FINGERPRINT.device_brand, - device_model: DEVICE_FINGERPRINT.device_model, - os_version: DEVICE_FINGERPRINT.os_version, - channel: DEVICE_FINGERPRINT.channel, - openudid: body.header.openudid, - cdid: body.header.cdid, - } - setCredentials('_qs_device', device as any) - console.log(`[qs/device] 设备注册成功: device_id=${device.device_id}`) - return device -} - -let cachedDevice: QsDevice | null = null - -/** 取当前设备(无则注册并持久化) */ -export const getDevice = async (): Promise => { - if (cachedDevice) return cachedDevice - const stored = getCredentials('_qs_device') as QsDevice | null - if (stored?.device_id && stored?.install_id) { - cachedDevice = stored - return cachedDevice - } - cachedDevice = await registerDevice() - return cachedDevice -} - -/** 设备是否已注册(不触发注册) */ -export const hasDevice = (): boolean => - Boolean(cachedDevice || (getCredentials('_qs_device') as QsDevice | null)?.device_id) - -/** - * 设备身份(同步读,不触发注册):已注册设备优先;尚未注册时用独立生成的 - * `_qs_passport_device`,避免覆盖注册记录。护照/签名模块共用,保证「同一设备」。 - */ -export const getDeviceIds = (): { deviceId: string; installId: string } => { - const registered = getCredentials('_qs_device') as QsDevice | null - if (registered?.device_id && registered?.install_id) { - return { deviceId: String(registered.device_id), installId: String(registered.install_id) } - } - const existing = getCredentials('_qs_passport_device') as { deviceId?: string; installId?: string } | null - if (existing?.deviceId && existing.installId) return { deviceId: String(existing.deviceId), installId: String(existing.installId) } - const now = Date.now() - const ids = { deviceId: String(now), installId: String(now + 1) } - setCredentials('_qs_passport_device', ids) - return ids -} - -export default { registerDevice, getDevice, hasDevice, getDeviceIds } diff --git a/sdk/qs/index.ts b/sdk/qs/index.ts index 5dd5660..2d0a478 100644 --- a/sdk/qs/index.ts +++ b/sdk/qs/index.ts @@ -1,7 +1,8 @@ // 汽水音乐(字节跳动)平台模块 -// 匿名可用面(2026-10-01 实测):搜索 / 曲目信息 / 歌词 / 封面 / 30 秒试听取流(未加密直链)。 -// 登录态(/login/qs/qr 抖音扫码):设备注册 + X-Argus/X-Ladon 自签走 /luna/pc/track_v2, -// 加密音频服务端解密后经 /stream/qs/:id 代理回明文流(完整曲目,2026-10-01 全链路实测) +// 匿名可用面(2026-10-01 实测):曲目信息 / 歌词 / 封面 / 30 秒试听取流(未加密直链)。 +// 搜索与登录态取流需 PC 签名(QS_SIGN_SERVER 或固定值,见 sdk/qs/mssdk.ts 与 qs-signer 仓库): +// 登录态(/login/qs/qr 扫码)走 X-Helios/X-Medusa 签名 /luna/pc/track_v2, +// 加密音频服务端解密后经 /stream/qs/:id 代理回明文流(完整曲目,2026-10-01 全链路实测) import qsApi, { getStreamBuffer } from '../apis/qs.ts' import getLyric from './lyric.ts' import getMusicInfo, { coverUrl } from './musicInfo.ts' diff --git a/sdk/qs/mssdk.ts b/sdk/qs/mssdk.ts index c7ab600..dea0d51 100644 --- a/sdk/qs/mssdk.ts +++ b/sdk/qs/mssdk.ts @@ -1,28 +1,66 @@ -// 汽水 PC 通道签名(X-Helios / X-Medusa)—— 两种供给方式,按优先级: -// 1) 签名服务器:环境变量 QS_SIGN_SERVER 指向签名服务(如 http://127.0.0.1:8899, +// 汽水 PC 通道签名(X-Helios / X-Medusa)—— 三种供给方式,按优先级: +// 1) 签名服务器(显式):环境变量 QS_SIGN_SERVER 指向签名服务(如 http://127.0.0.1:16889, // 可选 QS_SIGN_TOKEN 配 Bearer 鉴权),HTTP POST /sign。 -// 契约同时兼容独立仓库 qs-signer(gitea.4i.hk/hhhge/qs-signer,本地与 MusicServer 同级) -// 的两个服务(请求 {url, headers, body?},响应 {ok, headers, added?}): -// - server.mjs (Wine + bdms.node) -// - mssdk-sign/ (metasecml.dll 独立进程,见其 README) -// 2) 固定值兜底:无签名服务器时,两个加密头直接用抓包固定值 —— +// 契约兼容独立仓库 qs-signer(gitea.4i.hk/hhhge/qs-signer,本地与 MusicServer 同级) +// 的 mssdk-sign 服务(请求 {url, headers, body?},响应 {ok, headers, added?})。 +// 2) 签名服务器(自动探测):QS_SIGN_SERVER 未配置时,进程启动探测本机 16689 端口—— +// GET /healthz 应答 ok:true 且 service:"mssdk-sign" 才认定为汽水签名服务并自动接入 +// (16689 上是别的服务则忽略,端口没人听静默跳过,均回落到 3)。 +// 3) 固定值兜底:无签名服务器时,两个加密头直接用抓包固定值 —— // 环境变量 QS_HELIOS / QS_MEDUSA,或 data/qs-sign.json: // { "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" } // 签名与设备/URL 绑定:固定值必须搭配抓包时的同一套 device_id/install_id -// (json 里带上即自动对齐 URL 参数),时效自负,被风控时走安卓通道兜底。 -// 两者都没有:PC 通道不可用,搜索/取流自动走安卓自签通道。 +// (json 里带上即自动对齐 URL 参数),时效自负。 +// 两者都没有:PC 签名不可用 —— 搜索/取流走 track_v2 的通道直接报错(匿名试听不受影响)。 // 官方只对 qishui.com 且 **非** /passport/、非 /ttwid/ 的请求签名 —— 登录链路用不到本模块。 // 签名覆盖 URL + 全部请求头;POST 的 x-ss-stub(body 的 MD5 大写)既是签名输入也要随请求发出。 import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { getDeviceIds } from './device.ts' +import { getDeviceIds } from './passport.ts' // 仓库根(本文件在 sdk/qs/ 下)——不依赖 process.cwd(),从任何目录启动都能找到 const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const FIXED_SIGN_FILE = join(REPO_ROOT, 'data', 'qs-sign.json') const SIGN_TIMEOUT = 5000 +// —— 端口探测:QS_SIGN_SERVER 未配置时,启动探测本机 16689 是不是汽水签名服务 —— +const PROBE_BASE = 'http://127.0.0.1:16689' +const PROBE_TIMEOUT = 2000 + +// undefined=探测未完成;null=探测过、不是签名服务;字符串=探测到的签名服务地址 +let probedServer: string | null | undefined +let probing: Promise | null = null + +/** healthz 应答 ok:true 且 service:"mssdk-sign" 才认——16689 上可能是无关服务 */ +const isSignService = (json: any): boolean => json?.ok === true && json?.service === 'mssdk-sign' + +const probeOnce = async (): Promise => { + try { + const res = await fetch(`${PROBE_BASE}/healthz`, { signal: AbortSignal.timeout(PROBE_TIMEOUT) }) + const json = await res.json().catch(() => null) as any + if (res.ok && isSignService(json)) { + probedServer = PROBE_BASE + console.log(`[qs/mssdk] 探测到本机 16689 是汽水签名服务(mssdk-sign v${json.version ?? '?'},dll ${json.dllVersion ?? '?'}),自动接入`) + } else { + probedServer = null + console.log(`[qs/mssdk] 本机 16689 有服务但不是汽水签名服务(healthz service=${json?.service ?? '非 JSON'}),忽略`) + } + } catch { + probedServer = null // 端口没人监听是常态,静默 + } +} + +/** 确保探测完成(进程内只探一次);显式配置了 QS_SIGN_SERVER 则永不探测 */ +const ensureProbe = async (): Promise => { + if (probedServer !== undefined || process.env.QS_SIGN_SERVER?.trim()) return + probing ??= probeOnce() + await probing +} + +// 启动即探测一次(异步进行,不阻塞导入);探测期间进来的签名调用会等它出结果 +void ensureProbe() + /** 固定值配置(helios/medusa 必填;device_id/install_id 应与抓包时同一套) */ interface FixedSignConfig { helios: string @@ -75,16 +113,22 @@ type SignMode = | { kind: 'server'; server: string; token: string } | { kind: 'fixed'; fixed: FixedSignConfig } -/** 当前签名供给方式:签名服务器 > 固定值 > 不可用(null) */ +/** 当前签名供给方式:显式服务器 > 探测接入的服务器 > 固定值 > 不可用(null) */ const loadSignMode = (): SignMode | null => { const server = process.env.QS_SIGN_SERVER?.trim().replace(/\/+$/, '') if (server) return { kind: 'server', server, token: process.env.QS_SIGN_TOKEN?.trim() ?? '' } + if (probedServer) { + // 探测接入;签名服务开了鉴权时同样吃 QS_SIGN_TOKEN + return { kind: 'server', server: probedServer, token: process.env.QS_SIGN_TOKEN?.trim() ?? '' } + } const fixed = readFixedConfig() return fixed ? { kind: 'fixed', fixed } : null } -/** PC 签名是否可用(只看配置;服务器宕机等运行期失败由签名调用返回 null、调用方降级处理) */ -export const mssdkAvailable = (): boolean => Boolean(loadSignMode()) +/** PC 签名是否可用(只看配置与探测结果;服务器宕机等运行期失败由签名调用返回 null、调用方降级处理) */ +export const mssdkAvailable = (): boolean => + // 探测未完成时先放行——紧随其后的 signQishuiRequest 会等探测结果再定供给 + Boolean(loadSignMode()) || probedServer === undefined const signViaServer = async ( server: string, @@ -125,13 +169,14 @@ const signViaServer = async ( * 给一个 qishui.com 请求签名 * @param headers 必须是「真实要发出去的那一份」(含 cookie / x-ss-stub) * @param body POST body(给了且 headers 没带 x-ss-stub 时服务端会自动补) - * @returns 需追加的头;不可用时返回 null(调用方走安卓通道) + * @returns 需追加的头;不可用时返回 null(调用方自行降级处理) */ export const signQishuiRequest = async ( url: string, headers: Record, body?: string, ): Promise | null> => { + await ensureProbe() // 探测未完成时先等它出结果,再决定供给方式 const mode = loadSignMode() if (!mode) return null if (mode.kind === 'fixed') return { 'X-Helios': mode.fixed.helios, 'X-Medusa': mode.fixed.medusa } diff --git a/sdk/qs/musicSearch.ts b/sdk/qs/musicSearch.ts index 74fe695..ec5a73b 100644 --- a/sdk/qs/musicSearch.ts +++ b/sdk/qs/musicSearch.ts @@ -1,86 +1,26 @@ // 汽水音乐搜索(App 搜索接口直连) // 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor= -// 两条通道(同一接口,差在公共参数与签名,返回结构一致): -// 官方 PC 通道: PC 签名可用(签名服务器或固定值,见 sdk/qs/mssdk.ts)时优先—— -// luna_pc 参数 + X-Helios/X-Medusa;签名失败/被风控自动降级安卓通道; -// 安卓指纹通道: 静态安卓设备参数,缺参数或用 PC 参数不签名会被静默风控(200 + 空 body); -// 被风控时需换新指纹。 +// 官方 PC 通道: luna_pc 参数 + X-Helios/X-Medusa 签名(签名服务器或固定值,见 sdk/qs/mssdk.ts)。 +// PC 签名不可用时搜索直接报错;被风控时上游返回 200 + 空 body(无 result_groups)。 import { sizeFormate, formatPlayTime } from '../index.ts' import { httpFetch } from '../common/request.ts' import { coverUrl } from './musicInfo.ts' import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from './mssdk.ts' -// 安卓 App 设备参数指纹(社区验证可用的静态设备 id,与 musicdl 一致;被风控时需换新) -const SEARCH_DEVICE_PARAMS = { - device_platform: 'android', - os: 'android', - ssmix: 'a', - cdid: '46556f98-1720-4248-83da-62b74b60b46a', - channel: 'xiaomi_8478_64', - aid: '386088', - app_name: 'luna', - version_code: '100198030', - version_name: '19.8.0', - manifest_version_code: '100198030', - update_version_code: '100198030', - resolution: '1080*1920', - dpi: '480', - device_type: 'ABR-AL80', - device_brand: 'HUAWEI', - language: 'zh', - os_api: '35', - os_version: '15', - ac: 'wifi', - device_model: 'ABR-AL80', - save_power: '0', - font_size: '1.00', - luna_first_launch_apk_type: 'normal_apk', - diversion_channel_name: 'xiaomi_8478_64', - is_car_play: '0', - battery: '0.99', - network_speed: '10156', - hybrid_version_code: '100198030', - tz_name: 'Asia/Shanghai', - tz_offset: '28800', - luna_register_time: '1784311292', - package: 'com.luna.music', - charge: '0', - luna_apk_type: 'normal_apk', - output_device_type: 'Phone', - volume: '1.00', - brightness: '0.08', - need_personal_recommend: '1', - is_teen_mode: '0', - sim_region: 'cn', - android_device_type: 'default', - iid: '2204957404569386', - device_id: '2204957404565290', -} - -const SEARCH_UA = 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80' const PC_PAGE_SIZE = 20 -// PC 通道必须配 X-Helios/X-Medusa(否则上游 200 + 空 body);安卓通道用静态设备指纹 -const buildSearchUrl = (str: string, cursor: number, count: number, pcChannel: boolean) => { - const params = pcChannel - ? new URLSearchParams({ - ...pcCommonParams(), - q: str, - cursor: String(cursor), - search_id: '', - search_method: 'input', - debug_params: '', - from_search_id: '', - search_scene: 'normal', - scene_name: 'search', - }) - : new URLSearchParams({ - ...SEARCH_DEVICE_PARAMS, - _rticket: String(Date.now()), - q: str, - cursor: String(cursor), - count: String(count), - }) +const buildSearchUrl = (str: string, cursor: number): string => { + const params = new URLSearchParams({ + ...pcCommonParams(), + q: str, + cursor: String(cursor), + search_id: '', + search_method: 'input', + debug_params: '', + from_search_id: '', + search_scene: 'normal', + scene_name: 'search', + }) return `https://api.qishui.com/luna/search/track?${params.toString()}` } @@ -131,44 +71,21 @@ export default { allPage: this.allPage, limit: this.limit, total, - source: 'qs', + source: 'qs' as const, } }, - async search(str, page = 1, limit, retryNum = 0, forceAndroid = false) { - if (++retryNum > 3) return Promise.reject(new Error('try max num')) - const pcChannel = !forceAndroid && mssdkAvailable() - if (pcChannel) { - // PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠 - const pageSize = PC_PAGE_SIZE - const cursor = pageSize * (page - 1) - const url = buildSearchUrl(str, cursor, pageSize, true) - // 签名覆盖真实发出的头:先定头、再签、再发 - const headers: Record = { 'User-Agent': PC_UA } - const fallbackToAndroid = (reason: string) => { - console.log(`[qs/search] PC 通道${reason},降级安卓通道`) - return this.search(str, page, limit, retryNum, true) - } - let body: any = null - try { - // 签名失败(服务器宕机/固定值被风控)与请求异常都降级安卓通道 - const signed = await signQishuiRequest(url, headers) - if (!signed) return fallbackToAndroid('签名不可用') - const searchRequest = await httpFetch(url, { method: 'get', headers: { ...headers, ...signed }, timeout: 15000 }).promise - body = searchRequest.body - } catch (err: any) { - return fallbackToAndroid(`请求失败: ${err?.message ?? err}`) - } - // 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}}) - if (!body?.result_groups) return fallbackToAndroid('空结果(疑似风控)') - return this.handleSearchBody(body, cursor, page) - } - // 安卓指纹通道:无签名直发,空结果时换 _rticket 重试 - const pageSize = limit ?? this.limit - const cursor = pageSize * (page - 1) - const url = buildSearchUrl(str, cursor, pageSize, false) - const headers: Record = { 'User-Agent': SEARCH_UA } - const { body } = await httpFetch(url, { method: 'get', headers, timeout: 15000 }).promise - if (!body?.result_groups) return this.search(str, page, limit, retryNum) + async search(str, page = 1) { + if (!mssdkAvailable()) throw new Error('PC 签名不可用(16689 未探测到签名服务,可配置 QS_SIGN_SERVER 或 data/qs-sign.json,见 qs-signer 仓库)') + // PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠 + const cursor = PC_PAGE_SIZE * (page - 1) + const url = buildSearchUrl(str, cursor) + // 签名覆盖真实发出的头:先定头、再签、再发 + const headers: Record = { 'User-Agent': PC_UA } + const signed = await signQishuiRequest(url, headers) + if (!signed) throw new Error('官方签名失败') + const { body } = await httpFetch(url, { method: 'get', headers: { ...headers, ...signed }, timeout: 15000 }).promise + // 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}}) + if (!body?.result_groups) throw new Error('搜索无结果(疑似风控)') return this.handleSearchBody(body, cursor, page) }, } diff --git a/sdk/qs/passport.ts b/sdk/qs/passport.ts index 793d597..2d9d225 100644 --- a/sdk/qs/passport.ts +++ b/sdk/qs/passport.ts @@ -11,7 +11,6 @@ import os from 'node:os' import QRCode from 'qrcode' import { httpFetch } from '../common/request.ts' import { getCredentials, setCredentials } from '../auth/store.ts' -import { getDeviceIds } from './device.ts' const PASSPORT_HOST = 'https://api.qishui.com' const SCAN_LOGIN_URL = 'https://bff-pc.qishui.com/light/invoke/scan_login' @@ -30,6 +29,19 @@ const PASSPORT_FIXED = { version_code: '30020100', } +/** + * PC 设备身份(同步读,首次懒生成并持久化):passport 登录与 PC 签名公共参数共用, + * 持久化于 data/auth.json 的 `_qs_passport_device`,保证同一实例「同一设备」。 + */ +export const getDeviceIds = (): { deviceId: string; installId: string } => { + const existing = getCredentials('_qs_passport_device') as { deviceId?: string; installId?: string } | null + if (existing?.deviceId && existing.installId) return { deviceId: String(existing.deviceId), installId: String(existing.installId) } + const now = Date.now() + const ids = { deviceId: String(now), installId: String(now + 1) } + setCredentials('_qs_passport_device', ids) + return ids +} + // 各 passport 请求共用的公共参数(设备身份 + 平台/版本) const commonParams = (): Record => { const { deviceId, installId } = getDeviceIds()