Files
MusicServer/sdk/qs/audiodecrypt.ts
T
snowy-yang 6b05fde64b 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 固定值兜底。
2026-10-04 17:31:33 +08:00

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,
}