File size: 9,099 Bytes
00a912e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
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'
}