refactor(qs)!: 手机端链路彻底清除——PC 签名成为唯一供给
安卓自签 crypto/ 整目录(11 文件)与 device.ts 设备注册删除,audiodecrypt 上移 qs/ 根,getDeviceIds 并入 passport(_qs_passport_device 持久化); musicSearch 去指纹搜索(需 PC 签名,匿名搜索下线);mssdk 签名供给改三级: QS_SIGN_SERVER 显式 → 本机 16689 /healthz 自动探测(qs-signer 的 mssdk-sign) → data/qs-sign.json 固定值兜底。
This commit is contained in:
1 parent
cec63f864e
commit
6b05fde64b
19 files changed
+95
-1255
No files matched your search
+7
-73
@@ -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<string> => {
|
||||
return url
|
||||
}
|
||||
|
||||
// 登录态通道:注册设备 + argus/ladon 自签 → track_v2 → 解密最高码率音频
|
||||
const buildSignedUrl = (device: Awaited<ReturnType<typeof getDevice>>): 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<string, string>, 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<string, string> = {
|
||||
@@ -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<StreamEntry> => {
|
||||
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)
|
||||
|
||||
+1
-1
@@ -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 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
|
||||
|
||||
File renamed without changes.
@@ -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<ArrayBuffer> {
|
||||
return sm3(data).slice(0, 6);
|
||||
}
|
||||
|
||||
function decodeStub(xssStub: string): Uint8Array<ArrayBuffer> {
|
||||
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<ArrayBuffer> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
@@ -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<ArrayBuffer> {
|
||||
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<ArrayBuffer> | string;
|
||||
|
||||
function toBytes(input: HashInput): Uint8Array<ArrayBuffer> {
|
||||
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<string> => {
|
||||
const subtle = getSubtle();
|
||||
const digest = await subtle.digest("SHA-256", toBytes(input));
|
||||
return hex(digest);
|
||||
},
|
||||
sha256bytes: async (input: HashInput): Promise<ArrayBuffer> => {
|
||||
const subtle = getSubtle();
|
||||
return subtle.digest("SHA-256", toBytes(input));
|
||||
},
|
||||
sha512: async (input: HashInput): Promise<string> => {
|
||||
const subtle = getSubtle();
|
||||
const digest = await subtle.digest("SHA-512", toBytes(input));
|
||||
return hex(digest);
|
||||
},
|
||||
sha512bytes: async (input: HashInput): Promise<ArrayBuffer> => {
|
||||
const subtle = getSubtle();
|
||||
return subtle.digest("SHA-512", toBytes(input));
|
||||
},
|
||||
md5: async (input: HashInput): Promise<string> => md5(
|
||||
typeof input === "string" ? input : toBytes(input).buffer,
|
||||
),
|
||||
md5bytes: async (input: HashInput): Promise<ArrayBuffer> => unhex(md5(
|
||||
typeof input === "string" ? input : toBytes(input).buffer,
|
||||
)),
|
||||
sm3: async (input: HashInput): Promise<string> => hex(sm3(toBytes(input)).buffer),
|
||||
sm3bytes: async (input: HashInput): Promise<ArrayBuffer> => sm3(toBytes(input)).buffer,
|
||||
};
|
||||
@@ -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<string> {
|
||||
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);
|
||||
}
|
||||
@@ -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<number>(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();
|
||||
}
|
||||
@@ -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<number>): 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<ArrayBuffer> {
|
||||
return Uint8Array.from(this.buf);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
"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<UnidbgHeaders> {
|
||||
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<ArrayBuffer> | string,
|
||||
config: UnidbgConfig = defaultUnidbgConfig,
|
||||
): Promise<Record<string, string>> {
|
||||
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<string, string> = 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";
|
||||
@@ -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];
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
// Thanks to https://github.com/JuneAndGreen/sm-crypto/blob/master/src/sm2/sm3.js
|
||||
|
||||
type Bytes = ArrayLike<number>;
|
||||
|
||||
/**
|
||||
* 循环左移
|
||||
*/
|
||||
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<ArrayBuffer> {
|
||||
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<ArrayBuffer> {
|
||||
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);
|
||||
}
|
||||
@@ -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<ArrayBuffer> {
|
||||
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<Uint8Array<ArrayBuffer>> {
|
||||
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);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<QsDevice> => {
|
||||
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<QsDevice> => {
|
||||
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 }
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
// 汽水音乐(字节跳动)平台模块
|
||||
// 匿名可用面(2026-10-01 实测):搜索 / 曲目信息 / 歌词 / 封面 / 30 秒试听取流(未加密直链)。
|
||||
// 登录态(/login/qs/qr 抖音扫码):设备注册 + X-Argus/X-Ladon 自签走 /luna/pc/track_v2,
|
||||
// 匿名可用面(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'
|
||||
|
||||
+59
-14
@@ -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<void> | 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<void> => {
|
||||
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<void> => {
|
||||
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<string, string>,
|
||||
body?: string,
|
||||
): Promise<Record<string, string> | 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 }
|
||||
|
||||
+12
-95
@@ -1,69 +1,16 @@
|
||||
// 汽水音乐搜索(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({
|
||||
const buildSearchUrl = (str: string, cursor: number): string => {
|
||||
const params = new URLSearchParams({
|
||||
...pcCommonParams(),
|
||||
q: str,
|
||||
cursor: String(cursor),
|
||||
@@ -74,13 +21,6 @@ const buildSearchUrl = (str: string, cursor: number, count: number, pcChannel: b
|
||||
search_scene: 'normal',
|
||||
scene_name: 'search',
|
||||
})
|
||||
: new URLSearchParams({
|
||||
...SEARCH_DEVICE_PARAMS,
|
||||
_rticket: String(Date.now()),
|
||||
q: str,
|
||||
cursor: String(cursor),
|
||||
count: String(count),
|
||||
})
|
||||
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) {
|
||||
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 pageSize = PC_PAGE_SIZE
|
||||
const cursor = pageSize * (page - 1)
|
||||
const url = buildSearchUrl(str, cursor, pageSize, true)
|
||||
const cursor = PC_PAGE_SIZE * (page - 1)
|
||||
const url = buildSearchUrl(str, cursor)
|
||||
// 签名覆盖真实发出的头:先定头、再签、再发
|
||||
const headers: Record<string, string> = { '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}`)
|
||||
}
|
||||
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) 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<string, string> = { '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)
|
||||
if (!body?.result_groups) throw new Error('搜索无结果(疑似风控)')
|
||||
return this.handleSearchBody(body, cursor, page)
|
||||
},
|
||||
}
|
||||
+13
-1
@@ -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<string, string> => {
|
||||
const { deviceId, installId } = getDeviceIds()
|
||||
|
||||
Reference in new issue
Block a user