- 逆向 SpadeA(黑桃A) PlayAuth 混淆,从 spade_a 还原 AES-128 内容密钥
- 实现 cenc-aes-ctr MP4 解密(senc 逐样本 CTR + stsd entry 修复),实测可播放
- 新增 /video/proxy/{key} 代理:稳定 key + 磁盘缓存(二次命中提速 130x)+
http.ServeContent 支持 Range/206/seek 流式播放
- /video 接口新增 output(url/proxy,默认 proxy) 与 quality(max/720p/档位key) 参数,
可由 config fanqie.video.* 配置默认值;无匹配清晰度时明确报错
- 代理映射复用 audio_cache 存储,抽取 loadMediaProxyEntry 共用;挂接 7 天缓存清理
- deploy.sh 新增 --sync-nginx:备份→重写→校验→失败自动回滚,同步视频代理段配置
361 lines
11 KiB
Go
361 lines
11 KiB
Go
package fqapi
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"encoding/base64"
|
||
"encoding/binary"
|
||
"fmt"
|
||
)
|
||
|
||
// ============ SpadeA(黑桃A)PlayAuth 解码 ============
|
||
//
|
||
// 番茄短剧/汽水音乐同源的本地 DRM:video_model 每个清晰度的 spade_a
|
||
// (PlayAuth)经混淆变换直接还原 16 字节 AES-128 内容密钥(32 位 hex 字符串)。
|
||
// 算法逆向自 SodaMusic v3.5.1(qishui-decrypt-tool),与番茄短剧样本实测一致。
|
||
|
||
func bitCount32(v uint32) int {
|
||
c := v
|
||
c -= (c >> 1) & 0x55555555
|
||
c = (c & 0x33333333) + ((c >> 2) & 0x33333333)
|
||
return int(((c + (c>>4)) & 0x0f0f0f0f) * 0x01010101 >> 24)
|
||
}
|
||
|
||
func decodeBase36Byte(v byte) int {
|
||
switch {
|
||
case v >= '0' && v <= '9':
|
||
return int(v - '0')
|
||
case v >= 'a' && v <= 'z':
|
||
return int(v-'a') + 10
|
||
}
|
||
return 0xff
|
||
}
|
||
|
||
// decodeSpadeInner 逐字节去混淆:与前一字节异或后减去索引 popcount 与常数
|
||
func decodeSpadeInner(data []byte) []byte {
|
||
result := make([]byte, len(data))
|
||
buff := append([]byte{0xfa, 0x55}, data...)
|
||
for i := 0; i < len(data); i++ {
|
||
raw := int(data[i]^buff[i]) - bitCount32(uint32(i)) - 21
|
||
if raw >= 0 {
|
||
result[i] = byte(raw)
|
||
} else {
|
||
result[i] = byte(((raw % 255) + 255) % 255)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// DecodeSpadeKey 从 spade_a(PlayAuth base64)解出 16 字节 AES 内容密钥
|
||
func DecodeSpadeKey(playAuthB64 string) ([]byte, error) {
|
||
raw, err := base64.StdEncoding.DecodeString(playAuthB64)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("spade_a base64 解码失败: %w", err)
|
||
}
|
||
if len(raw) < 4 {
|
||
return nil, fmt.Errorf("spade_a 长度不足: %d", len(raw))
|
||
}
|
||
padding := int(raw[0]^raw[1]^raw[2]) - 48
|
||
if padding < 0 || padding >= len(raw)-3 {
|
||
return nil, fmt.Errorf("spade_a padding 非法: %d", padding)
|
||
}
|
||
body := raw[1 : len(raw)-padding]
|
||
tmp := decodeSpadeInner(body)
|
||
keyLen := decodeBase36Byte(tmp[0])
|
||
if keyLen == 0xff {
|
||
return nil, fmt.Errorf("spade_a key 长度字符非法: %q", tmp[0])
|
||
}
|
||
end := len(body) - keyLen
|
||
if end < 1 || end > len(tmp) {
|
||
return nil, fmt.Errorf("spade_a key 范围非法: end=%d len=%d", end, len(tmp))
|
||
}
|
||
keyHex := string(tmp[1:end])
|
||
if len(keyHex) != 32 {
|
||
return nil, fmt.Errorf("spade_a key 长度异常: %d", len(keyHex))
|
||
}
|
||
key := make([]byte, 16)
|
||
for i := 0; i < 16; i++ {
|
||
if _, err := fmt.Sscanf(keyHex[i*2:i*2+2], "%02x", &key[i]); err != nil {
|
||
return nil, fmt.Errorf("spade_a key hex 非法: %s", keyHex)
|
||
}
|
||
}
|
||
return key, nil
|
||
}
|
||
|
||
// ============ CENC (cenc-aes-ctr) MP4 解密 ============
|
||
//
|
||
// 容器为标准 CENC MP4:moov 明文;加密 sample entry(encv/enca)+ senc box
|
||
// (version 0,每个 sample 8 字节 IV,整样本 AES-128-CTR,无 subsample)。
|
||
// 解密后把 entry 类型改回原始 codec(sinf/frma 指示),得到可播放文件。
|
||
|
||
type mp4Box struct {
|
||
typ string
|
||
contentStar int
|
||
end int
|
||
}
|
||
|
||
// walkBoxes 迭代 [start,end) 内的顶层 box,fn 返回 false 时停止迭代
|
||
func walkBoxes(data []byte, start, end int, fn func(mp4Box) bool) error {
|
||
p := start
|
||
for p+8 <= end {
|
||
size := int(binary.BigEndian.Uint32(data[p : p+4]))
|
||
typ := string(data[p+4 : p+8])
|
||
if size == 1 { // largesize
|
||
if p+16 > end {
|
||
return fmt.Errorf("box %s largesize 越界", typ)
|
||
}
|
||
size = int(binary.BigEndian.Uint64(data[p+8 : p+16]))
|
||
// ponytail: >2GB 文件不支持,截断风险可接受
|
||
if size > len(data) {
|
||
return fmt.Errorf("box %s 过大", typ)
|
||
}
|
||
}
|
||
if size < 8 || p+size > end {
|
||
return fmt.Errorf("box %s 尺寸非法 size=%d at %d", typ, size, p)
|
||
}
|
||
if !fn(mp4Box{typ: typ, contentStar: p + 8, end: p + size}) {
|
||
return nil
|
||
}
|
||
p += size
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DecryptCENCMP4 原地解密 CENC 加密的 MP4 字节并修复 stsd entry 类型。
|
||
// 仅支持 senc version 0 + 无 subsample 标志(番茄实测形态),其他形态返回错误。
|
||
func DecryptCENCMP4(data []byte, key []byte) error {
|
||
block, err := aes.NewCipher(key)
|
||
if err != nil {
|
||
return fmt.Errorf("内容密钥非法: %w", err)
|
||
}
|
||
|
||
decryptStream := func(ct []byte, iv8 []byte) error {
|
||
if len(iv8) != 8 {
|
||
return fmt.Errorf("senc IV 长度异常: %d", len(iv8))
|
||
}
|
||
iv := make([]byte, 16)
|
||
copy(iv, iv8) // 8 字节 IV 置于高位,低 8 字节为块计数器(CENC 标准)
|
||
cipher.NewCTR(block, iv).XORKeyStream(ct, ct)
|
||
return nil
|
||
}
|
||
|
||
// 找 moov 并遍历其下所有 trak,逐 trak 解密
|
||
var trakErr error
|
||
foundMoov := false
|
||
if err := walkBoxes(data, 0, len(data), func(b mp4Box) bool {
|
||
if b.typ != "moov" {
|
||
return true
|
||
}
|
||
foundMoov = true
|
||
_ = walkBoxes(data, b.contentStar, b.end, func(trak mp4Box) bool {
|
||
if trak.typ != "trak" {
|
||
return true
|
||
}
|
||
if err := decryptTrak(data, trak, decryptStream); err != nil {
|
||
trakErr = err
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
return false
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
if !foundMoov {
|
||
return fmt.Errorf("moov box 未找到")
|
||
}
|
||
return trakErr
|
||
}
|
||
|
||
// decryptTrak 处理单个 trak:解析 sample 表 + senc,逐样本解密,修补 stsd
|
||
func decryptTrak(data []byte, trak mp4Box, decryptStream func([]byte, []byte) error) error {
|
||
// 定位 mdia→minf→stbl
|
||
stbl, err := findBox(data, trak, "mdia", "minf", "stbl")
|
||
if err != nil || stbl == nil {
|
||
return err // 无 stbl 的 trak 跳过
|
||
}
|
||
|
||
var (
|
||
sizes []int
|
||
chunkOffsets []int
|
||
stscEntries [][2]int // {first_chunk, samples_per_chunk}
|
||
ivs [][]byte
|
||
stsd *mp4Box
|
||
haveSizes bool
|
||
haveOffsets bool
|
||
haveStsc bool
|
||
parseErr error
|
||
)
|
||
|
||
if err := walkBoxes(data, stbl.contentStar, stbl.end, func(b mp4Box) bool {
|
||
switch b.typ {
|
||
case "stsd":
|
||
stsd = &b
|
||
case "stsz":
|
||
constSize := int(binary.BigEndian.Uint32(data[b.contentStar+4 : b.contentStar+8]))
|
||
count := int(binary.BigEndian.Uint32(data[b.contentStar+8 : b.contentStar+12]))
|
||
if constSize != 0 {
|
||
for i := 0; i < count; i++ {
|
||
sizes = append(sizes, constSize)
|
||
}
|
||
} else {
|
||
sizes = make([]int, count)
|
||
for i := 0; i < count; i++ {
|
||
sizes[i] = int(binary.BigEndian.Uint32(data[b.contentStar+12+i*4 : b.contentStar+16+i*4]))
|
||
}
|
||
}
|
||
haveSizes = true
|
||
case "stco":
|
||
count := int(binary.BigEndian.Uint32(data[b.contentStar+4 : b.contentStar+8]))
|
||
for i := 0; i < count; i++ {
|
||
chunkOffsets = append(chunkOffsets, int(binary.BigEndian.Uint32(data[b.contentStar+8+i*4:b.contentStar+12+i*4])))
|
||
}
|
||
haveOffsets = true
|
||
case "co64":
|
||
count := int(binary.BigEndian.Uint32(data[b.contentStar+4 : b.contentStar+8]))
|
||
for i := 0; i < count; i++ {
|
||
v := binary.BigEndian.Uint64(data[b.contentStar+8+i*8 : b.contentStar+16+i*8])
|
||
if v > uint64(len(data)) {
|
||
parseErr = fmt.Errorf("co64 偏移越界")
|
||
return false
|
||
}
|
||
chunkOffsets = append(chunkOffsets, int(v))
|
||
}
|
||
haveOffsets = true
|
||
case "stsc":
|
||
count := int(binary.BigEndian.Uint32(data[b.contentStar+4 : b.contentStar+8]))
|
||
for i := 0; i < count; i++ {
|
||
first := int(binary.BigEndian.Uint32(data[b.contentStar+8+i*12 : b.contentStar+12+i*12]))
|
||
spc := int(binary.BigEndian.Uint32(data[b.contentStar+12+i*12 : b.contentStar+16+i*12]))
|
||
stscEntries = append(stscEntries, [2]int{first, spc})
|
||
}
|
||
haveStsc = true
|
||
case "senc":
|
||
verFlags := binary.BigEndian.Uint32(data[b.contentStar : b.contentStar+4])
|
||
if verFlags>>24 != 0 {
|
||
parseErr = fmt.Errorf("senc version %d 不支持", verFlags>>24)
|
||
return false
|
||
}
|
||
if verFlags&0b10 != 0 {
|
||
parseErr = fmt.Errorf("senc subsample 加密模式不支持")
|
||
return false
|
||
}
|
||
count := int(binary.BigEndian.Uint32(data[b.contentStar+4 : b.contentStar+8]))
|
||
p := b.contentStar + 8
|
||
if p+8*count > b.end {
|
||
parseErr = fmt.Errorf("senc IV 表越界")
|
||
return false
|
||
}
|
||
for i := 0; i < count; i++ {
|
||
ivs = append(ivs, data[p+8*i:p+8*i+8])
|
||
}
|
||
}
|
||
return true
|
||
}); err != nil {
|
||
return fmt.Errorf("stbl 解析失败: %w", err)
|
||
}
|
||
if parseErr != nil {
|
||
return parseErr
|
||
}
|
||
|
||
if len(ivs) == 0 {
|
||
return nil // 本 trak 未加密
|
||
}
|
||
if !haveSizes || !haveOffsets || !haveStsc {
|
||
return fmt.Errorf("sample 表不完整(stsz=%v stco=%v stsc=%v)", haveSizes, haveOffsets, haveStsc)
|
||
}
|
||
|
||
// stsc+stco+stsz 重建每样本绝对偏移(标准 sample table 遍历)
|
||
sampleIdx := 0
|
||
for ci, off := range chunkOffsets {
|
||
spc := stscEntries[len(stscEntries)-1][1]
|
||
for _, e := range stscEntries {
|
||
if e[0] <= ci+1 {
|
||
spc = e[1]
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
pos := off
|
||
for s := 0; s < spc && sampleIdx < len(sizes); s++ {
|
||
size := sizes[sampleIdx]
|
||
if sampleIdx >= len(ivs) {
|
||
break
|
||
}
|
||
if pos+size > len(data) {
|
||
return fmt.Errorf("sample %d 越界 pos=%d size=%d", sampleIdx, pos, size)
|
||
}
|
||
if err := decryptStream(data[pos:pos+size], ivs[sampleIdx]); err != nil {
|
||
return err
|
||
}
|
||
pos += size
|
||
sampleIdx++
|
||
}
|
||
}
|
||
|
||
if stsd != nil {
|
||
patchStsdEntry(data, *stsd)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// findBox 沿 path 逐层查找唯一子 box;任意一层缺失返回 (nil, nil)
|
||
func findBox(data []byte, parent mp4Box, path ...string) (*mp4Box, error) {
|
||
cur := parent
|
||
for _, name := range path {
|
||
var next *mp4Box
|
||
if err := walkBoxes(data, cur.contentStar, cur.end, func(b mp4Box) bool {
|
||
if b.typ == name {
|
||
next = &b
|
||
return false
|
||
}
|
||
return true
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
if next == nil {
|
||
return nil, nil
|
||
}
|
||
cur = *next
|
||
}
|
||
return &cur, nil
|
||
}
|
||
|
||
// patchStsdEntry 把加密 sample entry(encv/enca)的类型改回 sinf/frma 指示的原始 codec。
|
||
// 番茄实测单 entry;entry 头部长度因音/视频而异,直接在 entry 字节里定位 "sinf"。
|
||
func patchStsdEntry(data []byte, stsd mp4Box) {
|
||
entryCount := int(binary.BigEndian.Uint32(data[stsd.contentStar+4 : stsd.contentStar+8]))
|
||
if entryCount != 1 {
|
||
return // ponytail: 多 entry 形态按需扩展
|
||
}
|
||
entryStart := stsd.contentStar + 8
|
||
entryEnd := stsd.end
|
||
|
||
// 在 entry 内定位 "sinf"(box 类型魔数),再验证其 box 头合法性
|
||
sinfOff := bytes.Index(data[entryStart:entryEnd], []byte("sinf"))
|
||
for sinfOff >= 0 {
|
||
boxStart := entryStart + sinfOff - 4 // size 字段起点
|
||
if boxStart >= entryStart+8 {
|
||
size := int(binary.BigEndian.Uint32(data[boxStart : boxStart+4]))
|
||
if size >= 12 && boxStart+size <= entryEnd {
|
||
sinfContent, sinfEnd := boxStart+8, boxStart+size
|
||
frmaOff := bytes.Index(data[sinfContent:sinfEnd], []byte("frma"))
|
||
if frmaOff >= 0 {
|
||
frmaContent := sinfContent + frmaOff + 4
|
||
if frmaContent+4 <= sinfEnd {
|
||
// entry 类型字段在 box 头的 type 位置(entryStart+4)
|
||
copy(data[entryStart+4:entryStart+8], data[frmaContent:frmaContent+4])
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
next := bytes.Index(data[entryStart+sinfOff+1:entryEnd], []byte("sinf"))
|
||
if next < 0 {
|
||
return
|
||
}
|
||
sinfOff = sinfOff + 1 + next
|
||
}
|
||
}
|