- sdk/qs 平台模块(安卓参数指纹匿名搜索)+ sdk/apis/qs 取流: 匿名 30s 试听,登录态走签名 track_v2 + spade key AES-CTR 解密 - /login/qs/qr 抖音扫码登录(授权后 sessionid 解锁完整曲目) - /stream/qs/:id 解密流代理,/url 返回的相对地址按请求 host 补全 - 回归测试覆盖 kw 双通道(VIP 真音频/降级链)与 qs 试听;start.bat 去掉 SOURCE_ENABLED 直启
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
// CENC 音频解密(AES-CTR 逐样本)—— 移植自 guowenye/qishui-api src/audioDecryptor.js
|
|
// spade_a/PlayAuth → 32 位 hex key;密音频(m4a CENC / flac)→ 明文音频 Buffer
|
|
import { createDecipheriv } from 'node:crypto'
|
|
|
|
|
|
|
|
const encryptedBoxTypes = new Set(['senc', 'saio', 'saiz', 'sinf', 'schi', 'tenc', 'schm', 'frma'])
|
|
const containerBoxTypes = new Set(['moov', 'trak', 'mdia', 'minf', 'stbl', 'stsd'])
|
|
const maxSampleCount = 200000
|
|
const maxChunkCount = 200000
|
|
const maxStscEntryCount = 20000
|
|
|
|
function decodeBase36(codePoint) {
|
|
if (codePoint >= 48 && codePoint <= 57) return codePoint - 48
|
|
if (codePoint >= 97 && codePoint <= 122) return codePoint - 97 + 10
|
|
return 0xFF
|
|
}
|
|
|
|
function bitCount(number) {
|
|
let value = number - ((number >> 1) & 0x55555555)
|
|
value = (value & 0x33333333) + ((value >> 2) & 0x33333333)
|
|
return (((value + (value >> 4)) & 0xF0F0F0F) * 0x1010101) >> 24
|
|
}
|
|
|
|
function decryptSpadeInner(spadeKey) {
|
|
const result = new Uint8Array(spadeKey)
|
|
const buffer = new Uint8Array(2 + spadeKey.length)
|
|
buffer.set([0xFA, 0x55], 0)
|
|
buffer.set(spadeKey, 2)
|
|
|
|
for (let index = 0; index < result.length; index += 1) {
|
|
let value = (spadeKey[index] ^ buffer[index]) - bitCount(index) - 21
|
|
while (value < 0) value += 0xFF
|
|
result[index] = value
|
|
}
|
|
return result
|
|
}
|
|
|
|
function decryptSpade(spadeKeyBytes) {
|
|
const spadeKeyLength = spadeKeyBytes.length
|
|
if (spadeKeyLength < 3) return ''
|
|
const paddingLength = (spadeKeyBytes[0] ^ spadeKeyBytes[1] ^ spadeKeyBytes[2]) - 48
|
|
if (spadeKeyLength < paddingLength + 2) return ''
|
|
const innerInput = spadeKeyBytes.slice(1, spadeKeyLength - paddingLength)
|
|
const decodedBuffer = decryptSpadeInner(innerInput)
|
|
if (decodedBuffer.length === 0) return ''
|
|
const skipBytes = decodeBase36(decodedBuffer[0])
|
|
const decodedMessageLength = spadeKeyLength - paddingLength - 2
|
|
const endIndex = 1 + decodedMessageLength - skipBytes
|
|
if (endIndex > decodedBuffer.length) return ''
|
|
return Buffer.from(decodedBuffer.slice(1, endIndex)).toString('utf8')
|
|
}
|
|
|
|
function decryptSpadeA(spadeA) {
|
|
try {
|
|
return decryptSpade(new Uint8Array(Buffer.from(String(spadeA || '').trim(), 'base64')))
|
|
} catch (_) {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function isHexKey(value) {
|
|
return typeof value === 'string' && /^[a-f0-9]{32}$/i.test(value.trim())
|
|
}
|
|
|
|
function resolveAudioKey(source: any = {}) {
|
|
const rawHexKey = source.hex_key || source.key
|
|
if (isHexKey(rawHexKey)) return rawHexKey.trim().toLowerCase()
|
|
|
|
const rawSpadeA = source.spade_a || source.spadeA
|
|
if (!rawSpadeA) throw new Error('spade_a 或 hex_key 不能为空')
|
|
const hexKey = decryptSpadeA(rawSpadeA).trim()
|
|
if (!isHexKey(hexKey)) throw new Error('spade_a 或 hex_key 无效')
|
|
return hexKey.toLowerCase()
|
|
}
|
|
|
|
function hexToBytes(hex) {
|
|
if (!isHexKey(hex)) throw new Error('hex_key 必须是 32 位十六进制 AES-128 密钥')
|
|
return Buffer.from(hex, 'hex')
|
|
}
|
|
|
|
function asUint8Array(input) {
|
|
if (input instanceof Uint8Array) return input
|
|
if (Buffer.isBuffer(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
|
|
if (input instanceof ArrayBuffer) return new Uint8Array(input)
|
|
throw new Error('音频数据必须是 Buffer、Uint8Array 或 ArrayBuffer')
|
|
}
|
|
|
|
function concatUint8Arrays(arrays: any[]) {
|
|
const totalLength = arrays.reduce((sum, array) => sum + array.length, 0)
|
|
const result = new Uint8Array(totalLength)
|
|
let offset = 0
|
|
for (const array of arrays) {
|
|
result.set(array, offset)
|
|
offset += array.length
|
|
}
|
|
return result
|
|
}
|
|
|
|
function readUint32BE(fileData, offset) {
|
|
if (!Number.isInteger(offset) || offset < 0 || offset + 4 > fileData.length) {
|
|
throw new Error('音频容器结构无效')
|
|
}
|
|
return new DataView(fileData.buffer, fileData.byteOffset, fileData.byteLength).getUint32(offset, false)
|
|
}
|
|
|
|
function writeUint32BE(value) {
|
|
const output = new Uint8Array(4)
|
|
new DataView(output.buffer).setUint32(0, value, false)
|
|
return output
|
|
}
|
|
|
|
function readAscii(fileData, start, end) {
|
|
return Buffer.from(fileData.subarray(start, end)).toString('ascii')
|
|
}
|
|
|
|
class MP4Box {
|
|
offset: number
|
|
size: number
|
|
type: string
|
|
data: Uint8Array
|
|
|
|
constructor(fileData: Uint8Array, offset: number) {
|
|
this.offset = offset
|
|
this.size = readUint32BE(fileData, offset)
|
|
this.type = readAscii(fileData, offset + 4, offset + 8)
|
|
this.data = fileData.subarray(offset + 8, offset + this.size)
|
|
}
|
|
|
|
static findBox(fileData: Uint8Array, boxType: string, offset = 0, end: number | null = null) {
|
|
const searchEnd = end === null ? fileData.length : end
|
|
let position = offset
|
|
while (position < searchEnd) {
|
|
if (position + 8 > searchEnd) break
|
|
const boxSize = readUint32BE(fileData, position)
|
|
if (boxSize === 0 || boxSize > searchEnd - position || boxSize < 8) break
|
|
const currentType = readAscii(fileData, position + 4, position + 8)
|
|
if (currentType === boxType) return new MP4Box(fileData, position)
|
|
position += boxSize
|
|
}
|
|
return null
|
|
}
|
|
}
|
|
|
|
function requireBox(box, name) {
|
|
if (!box) throw new Error(`音频容器缺少 ${name} box`)
|
|
return box
|
|
}
|
|
|
|
function parseStsz(stszData) {
|
|
const sampleSize = readUint32BE(stszData, 4)
|
|
const sampleCount = readUint32BE(stszData, 8)
|
|
if (sampleCount > maxSampleCount) throw new Error('音频样本数量超过限制')
|
|
if (sampleSize !== 0) return new Array(sampleCount).fill(sampleSize)
|
|
const requiredLength = 12 + sampleCount * 4
|
|
if (requiredLength > stszData.length) throw new Error('stsz box 样本大小数据不完整')
|
|
const sampleSizes: number[] = []
|
|
for (let index = 0; index < sampleCount; index += 1) {
|
|
sampleSizes.push(readUint32BE(stszData, 12 + index * 4))
|
|
}
|
|
return sampleSizes
|
|
}
|
|
|
|
function parseStsc(stscData) {
|
|
const entryCount = readUint32BE(stscData, 4)
|
|
if (entryCount > maxStscEntryCount) throw new Error('stsc entry 数量超过限制')
|
|
const requiredLength = 8 + entryCount * 12
|
|
if (requiredLength > stscData.length) throw new Error('stsc box 数据不完整')
|
|
const entries: any[] = []
|
|
for (let index = 0; index < entryCount; index += 1) {
|
|
const baseOffset = 8 + index * 12
|
|
entries.push({
|
|
firstChunk: readUint32BE(stscData, baseOffset),
|
|
samplesPerChunk: readUint32BE(stscData, baseOffset + 4),
|
|
id: readUint32BE(stscData, baseOffset + 8),
|
|
})
|
|
}
|
|
return entries
|
|
}
|
|
|
|
function parseSenc(sencData) {
|
|
const sampleCount = readUint32BE(sencData, 4)
|
|
if (sampleCount > maxSampleCount) throw new Error('senc 样本数量超过限制')
|
|
const requiredLength = 8 + sampleCount * 8
|
|
if (requiredLength > sencData.length) throw new Error('senc box IV 数据不完整')
|
|
const ivs: Uint8Array[] = []
|
|
let position = 8
|
|
for (let index = 0; index < sampleCount; index += 1) {
|
|
const iv = new Uint8Array(16)
|
|
iv.set(sencData.subarray(position, position + 8))
|
|
ivs.push(iv)
|
|
position += 8
|
|
}
|
|
return ivs
|
|
}
|
|
|
|
function calculateChunkOffsets(sampleSizes, stscEntries, chunkCount, baseOffset) {
|
|
const offsets: number[] = []
|
|
let currentOffset = baseOffset
|
|
let sampleIndex = 0
|
|
for (let chunkIndex = 1; chunkIndex <= chunkCount; chunkIndex += 1) {
|
|
offsets.push(currentOffset)
|
|
let samplesPerChunk = 0
|
|
for (let entryIndex = 0; entryIndex < stscEntries.length; entryIndex += 1) {
|
|
const currentEntry = stscEntries[entryIndex]
|
|
const nextEntry = stscEntries[entryIndex + 1]
|
|
if (chunkIndex >= currentEntry.firstChunk && (!nextEntry || chunkIndex < nextEntry.firstChunk)) {
|
|
samplesPerChunk = currentEntry.samplesPerChunk
|
|
break
|
|
}
|
|
}
|
|
for (let sampleOffset = 0; sampleOffset < samplesPerChunk; sampleOffset += 1) {
|
|
if (sampleIndex < sampleSizes.length) currentOffset += sampleSizes[sampleIndex]
|
|
sampleIndex += 1
|
|
}
|
|
}
|
|
return offsets
|
|
}
|
|
|
|
function updateStco(stcoData, offsets) {
|
|
const chunkCount = readUint32BE(stcoData, 4)
|
|
if (offsets.length < chunkCount) throw new Error('stco 偏移数量不足')
|
|
const header = stcoData.subarray(0, 8)
|
|
const body = new Uint8Array(chunkCount * 4)
|
|
const view = new DataView(body.buffer)
|
|
for (let index = 0; index < chunkCount; index += 1) view.setUint32(index * 4, offsets[index], false)
|
|
return concatUint8Arrays([header, body])
|
|
}
|
|
|
|
function processBoxTree(fileData, offset, size, newMdatOffset, context) {
|
|
const parts: Uint8Array[] = []
|
|
let position = offset + 8
|
|
const end = offset + size
|
|
|
|
while (position < end) {
|
|
if (position + 8 > end) {
|
|
parts.push(fileData.subarray(position, end))
|
|
break
|
|
}
|
|
const boxSize = readUint32BE(fileData, position)
|
|
if (boxSize < 8 || boxSize > end - position) {
|
|
parts.push(fileData.subarray(position, end))
|
|
break
|
|
}
|
|
const type = readAscii(fileData, position + 4, position + 8)
|
|
|
|
if (encryptedBoxTypes.has(type)) {
|
|
position += boxSize
|
|
continue
|
|
}
|
|
|
|
if (type === 'enca') {
|
|
const inner = processBoxTree(fileData, position, boxSize, newMdatOffset, context)
|
|
parts.push(writeUint32BE(inner.length + 8), Buffer.from('mp4a'), inner)
|
|
position += boxSize
|
|
continue
|
|
}
|
|
|
|
if (type === 'stco') {
|
|
const offsets = calculateChunkOffsets(context.sampleSizes, context.stscEntries, context.chunkCount, newMdatOffset)
|
|
const updatedBody = updateStco(fileData.subarray(position + 8, position + boxSize), offsets)
|
|
parts.push(writeUint32BE(updatedBody.length + 8), Buffer.from('stco'), updatedBody)
|
|
position += boxSize
|
|
continue
|
|
}
|
|
|
|
if (containerBoxTypes.has(type)) {
|
|
const inner = processBoxTree(fileData, position, boxSize, newMdatOffset, context)
|
|
parts.push(writeUint32BE(inner.length + 8), Buffer.from(type), inner)
|
|
position += boxSize
|
|
continue
|
|
}
|
|
|
|
parts.push(fileData.subarray(position, position + boxSize))
|
|
position += boxSize
|
|
}
|
|
|
|
return concatUint8Arrays(parts)
|
|
}
|
|
|
|
function scanForFlacMetadata(stsdData) {
|
|
const marker = Buffer.from('dfLa')
|
|
for (let index = 4; index < stsdData.length - 4; index += 1) {
|
|
if (
|
|
stsdData[index] === marker[0]
|
|
&& stsdData[index + 1] === marker[1]
|
|
&& stsdData[index + 2] === marker[2]
|
|
&& stsdData[index + 3] === marker[3]
|
|
) {
|
|
const boxSize = readUint32BE(stsdData, index - 4)
|
|
if (boxSize >= 8 && index - 4 + boxSize <= stsdData.length) {
|
|
return stsdData.subarray(index + 4, index - 4 + boxSize)
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function decryptCtr(encryptedSample, keyBytes, iv) {
|
|
const decipher = createDecipheriv(`aes-${keyBytes.length * 8}-ctr`, keyBytes, Buffer.from(iv))
|
|
decipher.setAutoPadding(false)
|
|
return Buffer.concat([decipher.update(Buffer.from(encryptedSample)), decipher.final()])
|
|
}
|
|
|
|
function decryptAudioBuffer(input, hexKey) {
|
|
const fileData = asUint8Array(input)
|
|
const keyBytes = hexToBytes(hexKey)
|
|
|
|
const ftyp = MP4Box.findBox(fileData, 'ftyp')
|
|
const moov = requireBox(MP4Box.findBox(fileData, 'moov'), 'moov')
|
|
const trak = requireBox(MP4Box.findBox(fileData, 'trak', moov.offset + 8, moov.offset + moov.size), 'trak')
|
|
const mdia = requireBox(MP4Box.findBox(fileData, 'mdia', trak.offset + 8, trak.offset + trak.size), 'mdia')
|
|
const minf = requireBox(MP4Box.findBox(fileData, 'minf', mdia.offset + 8, mdia.offset + mdia.size), 'minf')
|
|
const stbl = requireBox(MP4Box.findBox(fileData, 'stbl', minf.offset + 8, minf.offset + minf.size), 'stbl')
|
|
const stsd = requireBox(MP4Box.findBox(fileData, 'stsd', stbl.offset + 8, stbl.offset + stbl.size), 'stsd')
|
|
const stsz = requireBox(MP4Box.findBox(fileData, 'stsz', stbl.offset + 8, stbl.offset + stbl.size), 'stsz')
|
|
const stsc = requireBox(MP4Box.findBox(fileData, 'stsc', stbl.offset + 8, stbl.offset + stbl.size), 'stsc')
|
|
const stco = requireBox(MP4Box.findBox(fileData, 'stco', stbl.offset + 8, stbl.offset + stbl.size), 'stco')
|
|
const mdat = requireBox(MP4Box.findBox(fileData, 'mdat'), 'mdat')
|
|
|
|
let senc = MP4Box.findBox(fileData, 'senc', stbl.offset + 8, stbl.offset + stbl.size)
|
|
if (!senc) senc = MP4Box.findBox(fileData, 'senc', moov.offset + 8, moov.offset + moov.size)
|
|
requireBox(senc, 'senc')
|
|
|
|
const sampleSizes = parseStsz(stsz.data)
|
|
const stscEntries = parseStsc(stsc.data)
|
|
const chunkCount = readUint32BE(stco.data, 4)
|
|
if (chunkCount > maxChunkCount) throw new Error('音频 chunk 数量超过限制')
|
|
if (8 + chunkCount * 4 > stco.data.length) throw new Error('stco box 数据不完整')
|
|
const ivs = parseSenc(requireBox(senc, 'senc').data)
|
|
if (ivs.length < sampleSizes.length) throw new Error('senc IV 数量少于样本数量')
|
|
const encryptedPayloadSize = sampleSizes.reduce((sum, sampleSize) => sum + sampleSize, 0)
|
|
if (encryptedPayloadSize > mdat.size - 8) throw new Error('样本总大小超过 mdat 数据范围')
|
|
|
|
const decryptedSamples: Uint8Array[] = []
|
|
let sampleOffset = mdat.offset + 8
|
|
for (let index = 0; index < sampleSizes.length; index += 1) {
|
|
const sampleSize = sampleSizes[index]
|
|
if (sampleOffset + sampleSize > fileData.length) throw new Error('音频样本数据不完整')
|
|
decryptedSamples.push(decryptCtr(fileData.subarray(sampleOffset, sampleOffset + sampleSize), keyBytes, ivs[index]))
|
|
sampleOffset += sampleSize
|
|
}
|
|
|
|
const flacMetadata = scanForFlacMetadata(stsd.data)
|
|
if (flacMetadata) {
|
|
const metadataStart = flacMetadata.length > 4 ? 4 : 0
|
|
const outputBuffer = Buffer.from(concatUint8Arrays([Buffer.from('fLaC'), flacMetadata.subarray(metadataStart), ...decryptedSamples]))
|
|
return {
|
|
buffer: outputBuffer,
|
|
extension: '.flac',
|
|
content_type: 'audio/flac',
|
|
codec: 'flac',
|
|
sample_count: sampleSizes.length,
|
|
}
|
|
}
|
|
|
|
const context = { sampleSizes, stscEntries, chunkCount }
|
|
const ftypSize = ftyp ? ftyp.size : 0
|
|
const dummyMoov = processBoxTree(fileData, moov.offset, moov.size, 0, context)
|
|
const newMdatOffset = ftypSize + dummyMoov.length + 16
|
|
const cleanMoovData = processBoxTree(fileData, moov.offset, moov.size, newMdatOffset, context)
|
|
const cleanMoov = concatUint8Arrays([writeUint32BE(cleanMoovData.length + 8), Buffer.from('moov'), cleanMoovData])
|
|
const mdatData = concatUint8Arrays(decryptedSamples)
|
|
const newMdat = concatUint8Arrays([writeUint32BE(mdatData.length + 8), Buffer.from('mdat'), mdatData])
|
|
const finalParts: Uint8Array[] = []
|
|
if (ftyp) finalParts.push(fileData.subarray(ftyp.offset, ftyp.offset + ftyp.size))
|
|
finalParts.push(cleanMoov, newMdat)
|
|
|
|
return {
|
|
buffer: Buffer.from(concatUint8Arrays(finalParts)),
|
|
extension: '.m4a',
|
|
content_type: 'audio/mp4',
|
|
codec: 'mp4',
|
|
sample_count: sampleSizes.length,
|
|
}
|
|
}
|
|
|
|
export {
|
|
decryptSpadeA,
|
|
isHexKey,
|
|
resolveAudioKey,
|
|
decryptAudioBuffer,
|
|
scanForFlacMetadata,
|
|
MP4Box,
|
|
} |