ghp / packages /server /src /services /kugouEncryptedAudio.ts
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
Raw
History Blame Contribute Delete
9.1 kB
import { Transform } from 'node:stream'
import { LRUCache } from 'lru-cache'
export type KugouDecryptedFormat = 'flac' | 'ogg' | 'mp3' | 'm4a'
interface KugouQmcCipher {
decrypt(data: Uint8Array, offset: number): void
}
export interface KugouEncryptedAudio {
cipher: KugouQmcCipher
format: KugouDecryptedFormat
fileSize?: number
}
const encryptedAudio = new LRUCache<string, KugouEncryptedAudio>({ max: 500, ttl: 2 * 60 * 60 * 1000 })
const QMC_V2_PREFIX = Buffer.from('QQMusic EncV2,Key:')
const QMC_V2_KEY_1 = Buffer.from('386ZJY!@#*$%^&)(')
const QMC_V2_KEY_2 = Buffer.from('**#!(#$%&^a1cZ,T')
const QMC_RC4_FIRST_SEGMENT_SIZE = 128
const QMC_RC4_SEGMENT_SIZE = 5120
function teaDecryptBlock(data: Uint8Array, key: Uint8Array): Buffer {
const input = Buffer.from(data.buffer, data.byteOffset, data.byteLength)
const keyBuffer = Buffer.from(key.buffer, key.byteOffset, key.byteLength)
let left = input.readUInt32BE(0)
let right = input.readUInt32BE(4)
const keys = [0, 4, 8, 12].map((offset) => keyBuffer.readUInt32BE(offset))
const delta = 0x9e3779b9
let sum = Math.imul(delta, 16) >>> 0
for (let round = 0; round < 16; round += 1) {
const rightMix =
(((((left << 4) >>> 0) + keys[2]!) >>> 0) ^ ((left + sum) >>> 0) ^ ((left >>> 5) + keys[3]!)) >>> 0
right = (right - rightMix) >>> 0
const leftMix =
(((((right << 4) >>> 0) + keys[0]!) >>> 0) ^ ((right + sum) >>> 0) ^ ((right >>> 5) + keys[1]!)) >>> 0
left = (left - leftMix) >>> 0
sum = (sum - delta) >>> 0
}
const output = Buffer.allocUnsafe(8)
output.writeUInt32BE(left, 0)
output.writeUInt32BE(right, 4)
return output
}
export function decryptKugouTeaEnvelope(data: Uint8Array, key: Uint8Array): Buffer {
if (key.length !== 16 || data.length < 16 || data.length % 8 !== 0) {
throw new Error('Invalid QMC TEA payload')
}
const input = Buffer.from(data.buffer, data.byteOffset, data.byteLength)
let block = teaDecryptBlock(input.subarray(0, 8), key)
const paddingLength = block[0]! & 0x07
const outputLength = input.length - 1 - paddingLength - 2 - 7
if (outputLength < 0) throw new Error('Invalid QMC TEA padding')
let previousCipher: Uint8Array = Buffer.alloc(8)
let currentCipher: Uint8Array = input.subarray(0, 8)
let inputOffset = 8
let blockOffset = 1 + paddingLength
const nextBlock = () => {
if (inputOffset + 8 > input.length) throw new Error('Truncated QMC TEA payload')
previousCipher = currentCipher
currentCipher = input.subarray(inputOffset, inputOffset + 8)
for (let i = 0; i < 8; i += 1) block[i] = block[i]! ^ currentCipher[i]!
block = teaDecryptBlock(block, key)
inputOffset += 8
blockOffset = 0
}
for (let skipped = 0; skipped < 2; ) {
if (blockOffset < 8) {
blockOffset += 1
skipped += 1
} else {
nextBlock()
}
}
const output = Buffer.allocUnsafe(outputLength)
for (let outputOffset = 0; outputOffset < output.length; ) {
if (blockOffset < 8) {
output[outputOffset] = block[blockOffset]! ^ previousCipher[blockOffset]!
outputOffset += 1
blockOffset += 1
} else {
nextBlock()
}
}
for (let checked = 0; checked < 7; checked += 1) {
if (blockOffset === 8) nextBlock()
if (block[blockOffset] !== previousCipher[blockOffset]) throw new Error('Invalid QMC TEA checksum')
blockOffset += 1
}
return output
}
function decodeBase64(value: Uint8Array | string): Buffer {
const text = typeof value === 'string' ? value : Buffer.from(value).toString('ascii')
const normalized = text.replace(/\0+$/g, '').trim()
if (!normalized || normalized.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
throw new Error('Invalid QMC ekey encoding')
}
return Buffer.from(normalized, 'base64')
}
/** Decode a standard QMC2 ekey when the tracker returns that public format. */
export function deriveKugouQmcKey(ekey: string): Buffer {
let encryptedKey = decodeBase64(ekey)
if (encryptedKey.subarray(0, QMC_V2_PREFIX.length).equals(QMC_V2_PREFIX)) {
encryptedKey = decryptKugouTeaEnvelope(encryptedKey.subarray(QMC_V2_PREFIX.length), QMC_V2_KEY_1)
encryptedKey = decryptKugouTeaEnvelope(encryptedKey, QMC_V2_KEY_2)
encryptedKey = decodeBase64(encryptedKey)
}
const deriveV1 = (value: Buffer) => {
if (value.length < 16 || value.length % 8 !== 0) throw new Error('Invalid QMC ekey length')
const teaKey = Buffer.allocUnsafe(16)
for (let i = 0; i < 8; i += 1) {
teaKey[i * 2] = Math.floor(Math.abs(Math.tan(106 + i * 0.1)) * 100) & 0xff
teaKey[i * 2 + 1] = value[i]!
}
return Buffer.concat([value.subarray(0, 8), decryptKugouTeaEnvelope(value.subarray(8), teaKey)])
}
// Only accept the two formats implemented by the public QMC decoders. In
// particular, do not guess at tracker-specific trailers or nested TEA
// envelopes: a plausible-looking key would silently corrupt every byte.
return deriveV1(encryptedKey)
}
class QmcMapCipher implements KugouQmcCipher {
constructor(private readonly key: Uint8Array) {}
decrypt(data: Uint8Array, offset: number): void {
for (let i = 0; i < data.length; i += 1) {
let position = offset + i
if (position > 0x7fff) position %= 0x7fff
const keyIndex = (position * position + 71214) % this.key.length
const rotation = (keyIndex + 4) % 8
const value = this.key[keyIndex]!
const mask = ((value << rotation) | (value >>> rotation)) & 0xff
data[i] = data[i]! ^ mask
}
}
}
class QmcRc4Cipher implements KugouQmcCipher {
private readonly hash: number
private readonly keyStream: Uint8Array
constructor(private readonly key: Uint8Array) {
let hash = 1
for (const value of key) {
if (value === 0) continue
const next = Math.imul(hash, value) >>> 0
if (next === 0 || next <= hash) break
hash = next
}
this.hash = hash
const box = Uint8Array.from({ length: key.length }, (_, index) => index)
let swapIndex = 0
for (let index = 0; index < box.length; index += 1) {
swapIndex = (swapIndex + box[index]! + key[index]!) % box.length
;[box[index], box[swapIndex]] = [box[swapIndex]!, box[index]!]
}
this.keyStream = new Uint8Array(QMC_RC4_SEGMENT_SIZE + 512)
let left = 0
let right = 0
for (let index = 0; index < this.keyStream.length; index += 1) {
left = (left + 1) % box.length
right = (right + box[left]!) % box.length
;[box[left], box[right]] = [box[right]!, box[left]!]
this.keyStream[index] = box[(box[left]! + box[right]!) % box.length]!
}
}
private segmentSkip(segment: number, seed: number): number {
if (seed === 0) return 0
return Math.floor((this.hash / ((segment + 1) * seed)) * 100) % this.key.length
}
decrypt(data: Uint8Array, startOffset: number): void {
let processed = 0
let offset = startOffset
if (offset < QMC_RC4_FIRST_SEGMENT_SIZE) {
const length = Math.min(data.length, QMC_RC4_FIRST_SEGMENT_SIZE - offset)
for (let i = 0; i < length; i += 1) {
data[i] = data[i]! ^ this.key[this.segmentSkip(offset + i, this.key[(offset + i) % this.key.length]!)]!
}
processed += length
offset += length
}
while (processed < data.length) {
const segment = Math.floor(offset / QMC_RC4_SEGMENT_SIZE)
const segmentOffset = offset % QMC_RC4_SEGMENT_SIZE
const length = Math.min(data.length - processed, QMC_RC4_SEGMENT_SIZE - segmentOffset)
const streamOffset = (this.segmentSkip(segment, this.key[segment % this.key.length]!) & 0x1ff) + segmentOffset
for (let i = 0; i < length; i += 1) {
data[processed + i] = data[processed + i]! ^ this.keyStream[streamOffset + i]!
}
processed += length
offset += length
}
}
}
export function createKugouQmcCipher(ekey: string): KugouQmcCipher {
const key = deriveKugouQmcKey(ekey)
return key.length > 300 ? new QmcRc4Cipher(key) : new QmcMapCipher(key)
}
export function registerKugouEncryptedAudio(
url: string,
ekey: string,
format: KugouDecryptedFormat,
fileSize?: number,
): void {
encryptedAudio.set(url, { cipher: createKugouQmcCipher(ekey), format, fileSize })
}
export function getKugouEncryptedAudio(url: string): KugouEncryptedAudio | undefined {
return encryptedAudio.get(url)
}
export function createKugouDecryptStream(cipher: KugouQmcCipher, startOffset: number): Transform {
let offset = startOffset
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
try {
const decrypted = Buffer.from(chunk)
cipher.decrypt(decrypted, offset)
offset += decrypted.length
callback(null, decrypted)
} catch (error) {
callback(error instanceof Error ? error : new Error(String(error)))
}
},
})
}
export function kugouAudioContentType(format: KugouDecryptedFormat): string {
if (format === 'flac') return 'audio/flac'
if (format === 'ogg') return 'audio/ogg'
if (format === 'm4a') return 'audio/mp4'
return 'audio/mpeg'
}