commit 21045d0aad6dd95ed9869ae8debea51abe72bdd9 Author: pgmsoul Date: Sat May 23 12:55:48 2026 +0800 init: dec-music 项目初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0a5d30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# 编译产物 +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# IDE +.idea/ +*.iml +*.swp +*.swo +*~ + +# SSH 密钥(私钥) +id_* +!id_*.pub +*.pem +*.ppk + +# 测试数据文件 +**/testdata/*.bin +*.out +*.test +coverage.* + +# 系统文件 +Thumbs.db +.DS_Store + +# Go 工作区(如果将来使用) +go.work +go.work.sum diff --git a/README.md b/README.md new file mode 100644 index 0000000..685400d --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# dec-music + + +## 作为库使用 + +模块路径:`jsuse.com/dev/dec-music` +对外包名:`music` + +### API(与历史版本兼容) + +```go +import "jsuse.com/dev/dec-music" + +// 批量解密目录(递归子目录) +err := music.DecryptProcess(inputDir, outputDir, noSubOutput, onProcess) + +// 解密单个文件 +err := music.DecryptFile(inputFile, outputDir, noSubOutput, onProcess) +``` + +| 符号 | 说明 | +|------|------| +| `music.DecryptProcess` | 遍历 `inputDir` 下媒体文件并解密到 `outputDir` | +| `music.DecryptFile` | 只处理一个文件 | +| `music.OnProcess` | 可选回调 `func(src, dst, err string)`,`err` 为空表示成功 | +| `music.ErrNoDecoder` | 无可用解密器时的错误文案常量 | + +`noSubOutput == false` 时,输出目录会保留相对子目录结构;为 `true` 时所有文件平铺在 `outputDir` 下。 + +### 示例 + +```go +package main + +import ( + "fmt" + "jsuse.com/dev/dec-music" +) + +func main() { + err := music.DecryptProcess(`D:\Music\encrypted`, `D:\Music\decrypted`, false, + func(src, dst, errMsg string) { + if errMsg != "" { + fmt.Println("失败:", src, errMsg) + return + } + fmt.Println("成功:", src, "->", dst) + }) + if err != nil { + panic(err) + } +} +``` + +## 命令行工具(本地测试) + +CLI 位于 `cmd/dec-music`,依赖 `urfave/cli`,与库分离。 + +**不要在仓库根目录**对 `.` 执行 `go build -o xxx.exe`:根目录是 `package music`(库),没有 `main`。那样即使生成名为 `.exe` 的文件,也不是 Windows 可执行程序,运行会提示“不是有效的 Win32 应用”等。 + +```bash +# 正确:编译命令行程序 +go build -o dec-music.exe ./cmd/dec-music + +# 根目录仅校验库能否编译(不会在目录里生成可运行的 exe) +go build . +``` + +无参数运行 `dec-music.exe` 会打印用法(等同 `--help`)。 + +### 用法 + +```text +dec-music [选项] [输入路径] + +选项: + -i, --input <路径> 输入文件或目录(默认当前目录) + -o, --output <路径> 输出目录(默认与输入相同) + --no-sub-output 输出文件不保留子目录结构 + --skip-noop 跳过已是明文的音频(mp3/flac 等透传解码器) + --overwrite 覆盖已存在的输出文件 + --supported-ext 列出支持的加密扩展名并退出 + --qmc-mmkv <文件> macOS QQ 音乐 MMKV 密钥库路径 + --qmc-mmkv-key <密钥> 加密 MMKV 的 AES 密钥(可选) +``` + +### 示例 + +```bash +# 解密当前目录到 ./out +dec-music -i . -o ./out + +# 解密单个文件 +dec-music -i song.ncm -o ./out + +# 查看支持的扩展名 +dec-music --supported-ext + +# macOS QQ 音乐:指定 MMKV(可选) +dec-music -i ~/Music --qmc-mmkv "/path/to/MMKVStreamEncryptId" +``` + +成功时终端会打印彩色行:`操作成功: 源 -> 目标`(由 `log` 包输出)。 + +## 支持的格式 + +| 平台 | 常见扩展名 | +|------|------------| +| 网易云 | `.ncm` | +| QQ / 腾讯 | `.qmc0` `.qmc3` `.qmcflac` `.mgg` `.mflac` `.tkm` 等 | +| 酷狗 | `.kgm` `.kgma` `.vpr` | +| 酷我 | `.kwm` | +| 喜马拉雅 | `.x2m` `.x3m` `.xm` | +| 虾米等 | `.xm` `.wav` `.mp3`(部分) | + +完整列表以 `--supported-ext` 或 `decrypt.SupportedExtensions()` 为准。 + +## 项目结构 + +```text +dec-music/ +├── decrypt.go # package music:对外 API +├── decrypt/ # 各平台解密实现(内部) +├── mmkv/ # QQ Mac MMKV 密钥(内部,darwin) +├── internal/ +│ ├── crypto/ # AES 等 +│ ├── sniff/ # 输出格式嗅探 +│ └── fileutil/ # 媒体文件判断 +├── log/ # 可选彩色日志(CLI 使用) +└── cmd/dec-music/ # 命令行 +``` + +## 依赖说明 + +| 模块 | 用途 | +|------|------| +| (根模块库) | 仅标准库;`internal/tea` 为 QQ 密钥派生(源自 golang.org/x/crypto/tea,BSD) | +| `cmd/dec-music` | 额外需要 `github.com/urfave/cli/v2` | + +MMKV 的 protobuf 线格式解析已内置在 `mmkv/internal`,不依赖 `google.golang.org/protobuf`。 + + +## 开发 + +```bash +go build . +go test ./decrypt/... +go build ./cmd/dec-music +``` + +## 许可 + +与项目原有许可保持一致。`internal/tea` 摘自 golang.org/x/crypto(BSD),见该目录内源文件注释。 + +D:\Media\Kugou\Download\KugouMusic\张蔷 - 夜猫.kgma \ No newline at end of file diff --git a/cmd/dec-music/main.go b/cmd/dec-music/main.go new file mode 100644 index 0000000..96e7b0d --- /dev/null +++ b/cmd/dec-music/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/urfave/cli/v2" + "jsuse.com/dev/dec-music" + "jsuse.com/dev/dec-music/decrypt" + "jsuse.com/dev/dec-music/decrypt/qmc" + "jsuse.com/dev/dec-music/log" + + _ "jsuse.com/dev/dec-music/decrypt/kgm" + _ "jsuse.com/dev/dec-music/decrypt/kwm" + _ "jsuse.com/dev/dec-music/decrypt/ncm" + _ "jsuse.com/dev/dec-music/decrypt/qmc" + _ "jsuse.com/dev/dec-music/decrypt/tm" + _ "jsuse.com/dev/dec-music/decrypt/xiami" + _ "jsuse.com/dev/dec-music/decrypt/ximalaya" +) + +func main() { + app := newApp() + if len(os.Args) <= 1 { + _ = app.Run([]string{os.Args[0], "--help"}) + return + } + if err := app.Run(os.Args); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func newApp() *cli.App { + return &cli.App{ + Name: "dec-music", + Usage: "Decrypt encrypted music files (ncm, qmc, kgm, kwm, tm, ximalaya, etc.)", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "input", Aliases: []string{"i"}, Usage: "input file or directory"}, + &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "output directory (default: input directory)"}, + &cli.BoolFlag{Name: "no-sub-output", Usage: "write outputs flat into output directory"}, + &cli.BoolFlag{Name: "skip-noop", Usage: "skip plain (already decrypted) audio files"}, + &cli.BoolFlag{Name: "overwrite", Usage: "overwrite existing output files"}, + &cli.BoolFlag{Name: "supported-ext", Usage: "list supported extensions and exit"}, + &cli.StringFlag{Name: "qmc-mmkv", Usage: "QQ Music MMKV vault file (macOS)"}, + &cli.StringFlag{Name: "qmc-mmkv-key", Usage: "AES key for encrypted MMKV vault"}, + }, + Action: run, + } +} + +func run(c *cli.Context) error { + if c.Bool("supported-ext") { + printSupportedExtensions() + return nil + } + + input := c.String("input") + if input == "" { + switch c.Args().Len() { + case 0: + return cli.ShowAppHelp(c) // e.g. only flags, no input path + case 1: + input = c.Args().Get(0) + default: + return errors.New("specify one input file or directory") + } + } + + input, err := filepath.Abs(input) + if err != nil { + return fmt.Errorf("resolve input: %w", err) + } + + stat, err := os.Stat(input) + if err != nil { + return err + } + + output := c.String("output") + if output == "" { + if stat.IsDir() { + output = input + } else { + output = filepath.Dir(input) + } + } + if err := ensureDir(output); err != nil { + return err + } + + logger := log.New() + if mmkvPath := c.String("qmc-mmkv"); mmkvPath != "" { + if err := qmc.OpenMMKVCLI(mmkvPath, c.String("qmc-mmkv-key"), logger); err != nil { + return err + } + } + + if stat.IsDir() { + return music.DecryptProcessCLI(input, output, c.Bool("no-sub-output"), c.Bool("skip-noop"), c.Bool("overwrite")) + } + return music.DecryptFileCLI(input, output, c.Bool("no-sub-output"), c.Bool("skip-noop"), c.Bool("overwrite")) +} + +func ensureDir(path string) error { + stat, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return os.MkdirAll(path, 0o755) + } + return err + } + if !stat.IsDir() { + return errors.New("output must be a directory") + } + return nil +} + +func printSupportedExtensions() { + extSet := decrypt.SupportedExtensions() + exts := make([]string, 0, len(extSet)) + for ext := range extSet { + exts = append(exts, ext) + } + sort.Strings(exts) + for _, ext := range exts { + fmt.Printf("%s: %d\n", ext, extSet[ext]) + } +} diff --git a/decrypt.go b/decrypt.go new file mode 100644 index 0000000..1f12ef5 --- /dev/null +++ b/decrypt.go @@ -0,0 +1,276 @@ +package music + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "jsuse.com/dev/dec-music/decrypt" + "jsuse.com/dev/dec-music/internal/fileutil" + "jsuse.com/dev/dec-music/internal/sniff" + "jsuse.com/dev/dec-music/log" + + _ "jsuse.com/dev/dec-music/decrypt/kgm" + _ "jsuse.com/dev/dec-music/decrypt/kwm" + _ "jsuse.com/dev/dec-music/decrypt/ncm" + _ "jsuse.com/dev/dec-music/decrypt/qmc" + _ "jsuse.com/dev/dec-music/decrypt/tm" + _ "jsuse.com/dev/dec-music/decrypt/xiami" + _ "jsuse.com/dev/dec-music/decrypt/ximalaya" +) + +const ( + // ErrNoDecoder is returned when no decoder can handle the file. + ErrNoDecoder = "no suitable decoder" +) + +var defaultLogger = log.New() + +// OnProcess is called after each file is processed (err empty on success). +type OnProcess func(src, dst, err string) + +// DecryptFile decrypts a single file into outputDir. +func DecryptFile(inputFile, outputDir string, noSubOutput bool, onProcess OnProcess) error { + inputDir, err := filepath.Abs(filepath.Dir(inputFile)) + if err != nil { + return err + } + outDir, err := filepath.Abs(outputDir) + if err != nil { + return err + } + return decryptProcessFile(inputFile, outDir, inputDir, noSubOutput, false, false, onProcess) +} + +func decryptProcessFile(inputFile, outputDir, inputDir string, noSubOutput, skipNoop, overwrite bool, onProcess OnProcess) error { + proc := &processor{ + onProcess: onProcess, + logger: defaultLogger, + inputDir: inputDir, + outputDir: outputDir, + singleOutput: noSubOutput, + skipNoopDecoder: skipNoop, + overwriteOutput: overwrite, + } + return proc.processFile(inputFile) +} + +// DecryptProcess decrypts encrypted media under inputDir into outputDir. +// When noSubOutput is false, relative subdirectory structure is preserved. +func DecryptProcess(inputDir, outputDir string, noSubOutput bool, onProcess OnProcess) error { + return decryptProcessEx(inputDir, outputDir, noSubOutput, false, false, onProcess) +} + +// DecryptProcessCLI is like DecryptProcess but accepts skip-noop and overwrite (CLI helper). +func DecryptProcessCLI(inputDir, outputDir string, noSubOutput, skipNoop, overwrite bool) error { + return decryptProcessEx(inputDir, outputDir, noSubOutput, skipNoop, overwrite, nil) +} + +// DecryptFileCLI decrypts one file (CLI helper). +func DecryptFileCLI(inputFile, outputDir string, noSubOutput, skipNoop, overwrite bool) error { + inputDir, err := filepath.Abs(filepath.Dir(inputFile)) + if err != nil { + return err + } + outDir, err := filepath.Abs(outputDir) + if err != nil { + return err + } + return decryptProcessFile(inputFile, outDir, inputDir, noSubOutput, skipNoop, overwrite, nil) +} + +func decryptProcessEx(inputDir, outputDir string, noSubOutput, skipNoop, overwrite bool, onProcess OnProcess) error { + proc := &processor{ + onProcess: onProcess, + logger: defaultLogger, + inputDir: inputDir, + outputDir: outputDir, + singleOutput: noSubOutput, + skipNoopDecoder: skipNoop, + overwriteOutput: overwrite, + } + return proc.processDir(inputDir) +} + +type processor struct { + onProcess OnProcess + logger *log.Logger + inputDir string + outputDir string + skipNoopDecoder bool + overwriteOutput bool + singleOutput bool +} + +func (p *processor) processDir(inputDir string) error { + items, err := os.ReadDir(inputDir) + if err != nil { + return err + } + + var lastError error + for _, item := range items { + if strings.EqualFold(item.Name(), "temp") { + continue + } + filePath := filepath.Join(inputDir, item.Name()) + if item.IsDir() { + if err = p.processDir(filePath); err != nil { + lastError = err + } + continue + } + if !fileutil.IsMediaFile(item.Name()) { + continue + } + if err = p.processFile(filePath); err != nil { + lastError = err + if p.logger != nil { + p.logger.Error("conversion failed:", item.Name(), err) + } + } + } + if lastError != nil { + return fmt.Errorf("last error: %w", lastError) + } + return nil +} + +func (p *processor) processFile(filePath string) error { + factories := decrypt.GetDecoder(filePath, p.skipNoopDecoder) + if len(factories) == 0 { + return errors.New("skipping while no suitable decoder") + } + + if err := p.process(filePath, factories); err != nil { + if err.Error() == ErrNoDecoder { + // Fallback: copy as-is when no decoder matches (keeps original extension). + fallbackDst := filepath.Join(p.outputDir, filepath.Base(filePath)) + copyErr := p.copyFile(filePath, fallbackDst) + if copyErr == nil { + p.logResult(filePath, fallbackDst) + } + return copyErr + } + return err + } + return nil +} + +func (p *processor) logResult(inputFile, outPath string) { + inputRelDir, err := filepath.Rel(p.inputDir, filepath.Dir(inputFile)) + dstName := filepath.Base(outPath) + if !p.singleOutput { + dstName = filepath.Join(inputRelDir, filepath.Base(outPath)) + } + srcName := filepath.Join(inputRelDir, filepath.Base(inputFile)) + if err != nil { + if p.onProcess != nil { + p.onProcess(srcName, dstName, err.Error()) + } + return + } + if p.onProcess != nil { + p.onProcess(srcName, dstName, "") + } else { + log.PrintSuccess(srcName, dstName) + } +} + +func (p *processor) copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + return err +} + +func (p *processor) findDecoder(factories []decrypt.Factory, params *decrypt.DecoderParams) (decrypt.Decoder, *decrypt.Factory, error) { + for i := range factories { + factory := &factories[i] + dec := factory.Create(params) + if err := dec.Validate(); err == nil { + return dec, factory, nil + } + } + return nil, nil, errors.New("no any decoder can resolve the file") +} + +func (p *processor) process(inputFile string, factories []decrypt.Factory) error { + file, err := os.Open(inputFile) + if err != nil { + return err + } + defer file.Close() + + params := &decrypt.DecoderParams{ + Reader: file, + Extension: filepath.Ext(inputFile), + FilePath: inputFile, + Logger: p.logger, + } + + dec, factory, err := p.findDecoder(factories, params) + if err != nil { + return errors.New(ErrNoDecoder) + } + + header := bytes.NewBuffer(nil) + if _, err = io.CopyN(header, dec, 64); err != nil { + return fmt.Errorf("read header failed: %w", err) + } + audio := io.MultiReader(header, dec) + audioExt := sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3") + + inputRelDir, err := filepath.Rel(p.inputDir, filepath.Dir(inputFile)) + if err != nil { + return fmt.Errorf("get relative dir failed: %w", err) + } + + inFilename := strings.TrimSuffix(filepath.Base(inputFile), factory.Suffix) + outFolder := p.outputDir + if !p.singleOutput { + outFolder = filepath.Join(p.outputDir, inputRelDir) + } + if err = os.MkdirAll(outFolder, os.ModePerm); err != nil { + return err + } + outPath := filepath.Join(outFolder, inFilename+audioExt) + + if !p.overwriteOutput { + if _, err := os.Stat(outPath); err == nil { + if p.logger != nil { + p.logger.Info("output already exists, skipped:", outPath) + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat output file failed: %w", err) + } + } + + outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer outFile.Close() + + if _, err = io.Copy(outFile, audio); err != nil { + return err + } + + p.logResult(inputFile, outPath) + return nil +} diff --git a/decrypt/dispatch.go b/decrypt/dispatch.go new file mode 100644 index 0000000..8254491 --- /dev/null +++ b/decrypt/dispatch.go @@ -0,0 +1,59 @@ +package decrypt + +import ( + "io" + "path/filepath" + "strings" + + "jsuse.com/dev/dec-music/log" +) + +// DecoderParams configures a decoder instance. +type DecoderParams struct { + Reader io.ReadSeeker + Extension string + FilePath string + Logger *log.Logger +} + +// Factory creates decoders for a registered file suffix. +type Factory struct { + Suffix string + Noop bool + Create func(*DecoderParams) Decoder +} + +var registry []Factory + +// RegisterDecoder registers a file suffix handler (called from format init). +func RegisterDecoder(ext string, noop bool, fn func(*DecoderParams) Decoder) { + registry = append(registry, Factory{ + Noop: noop, Create: fn, Suffix: "." + strings.TrimPrefix(ext, "."), + }) +} + +// GetDecoder returns matching factories for a file name. +func GetDecoder(filename string, skipNoop bool) []Factory { + var result []Factory + name := strings.ToLower(filepath.Base(filename)) + for _, dec := range registry { + if !strings.HasSuffix(name, dec.Suffix) { + continue + } + if skipNoop && dec.Noop { + continue + } + result = append(result, dec) + } + return result +} + +// SupportedExtensions returns registered suffixes and handler counts. +func SupportedExtensions() map[string]int { + extSet := make(map[string]int) + for _, f := range registry { + ext := strings.TrimPrefix(f.Suffix, ".") + extSet[ext]++ + } + return extSet +} diff --git a/decrypt/interface.go b/decrypt/interface.go new file mode 100644 index 0000000..8c0e39e --- /dev/null +++ b/decrypt/interface.go @@ -0,0 +1,14 @@ +package decrypt + +import "io" + +// StreamDecoder decrypts audio stream bytes in place. +type StreamDecoder interface { + Decrypt(buf []byte, offset int) +} + +// Decoder decrypts one encrypted media file. +type Decoder interface { + Validate() error + io.Reader +} diff --git a/decrypt/kgm/kgm.go b/decrypt/kgm/kgm.go new file mode 100644 index 0000000..0afb732 --- /dev/null +++ b/decrypt/kgm/kgm.go @@ -0,0 +1,67 @@ +package kgm + +import ( + "fmt" + "io" + + "jsuse.com/dev/dec-music/decrypt" +) + +type decoder struct { + rd io.ReadSeeker + + cipher decrypt.StreamDecoder + offset int + + header header +} + +func newDecoder(p *decrypt.DecoderParams) decrypt.Decoder { + return &decoder{rd: p.Reader} +} + +// Validate checks if the file is a valid Kugou (.kgm, .vpr, .kgma) file. +// rd will be seeked to the beginning of the encrypted audio. +func (d *decoder) Validate() (err error) { + if err := d.header.FromFile(d.rd); err != nil { + return err + } + // TODO; validate crypto version + + switch d.header.CryptoVersion { + case 3: + d.cipher, err = newKgmCryptoV3(&d.header) + if err != nil { + return fmt.Errorf("kgm init crypto v3: %w", err) + } + default: + return fmt.Errorf("kgm: unsupported crypto version %d", d.header.CryptoVersion) + } + + // prepare for read + if _, err := d.rd.Seek(int64(d.header.AudioOffset), io.SeekStart); err != nil { + return fmt.Errorf("kgm seek to audio: %w", err) + } + + return nil +} + +func (d *decoder) Read(buf []byte) (int, error) { + n, err := d.rd.Read(buf) + if n > 0 { + d.cipher.Decrypt(buf[:n], d.offset) + d.offset += n + } + return n, err +} + +func init() { + // Kugou + decrypt.RegisterDecoder("kgm", false , newDecoder) + decrypt.RegisterDecoder("kgma", false , newDecoder) + // Viper + decrypt.RegisterDecoder("vpr", false , newDecoder) + // Kugou Android + decrypt.RegisterDecoder("kgm.flac", false , newDecoder) + decrypt.RegisterDecoder("vpr.flac", false , newDecoder) +} diff --git a/decrypt/kgm/kgm_header.go b/decrypt/kgm/kgm_header.go new file mode 100644 index 0000000..7eb8e30 --- /dev/null +++ b/decrypt/kgm/kgm_header.go @@ -0,0 +1,64 @@ +package kgm + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" +) + +var ( + vprHeader = []byte{ + 0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43, + 0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31, + } + kgmHeader = []byte{ + 0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B, + 0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14, + } + + ErrKgmMagicHeader = errors.New("kgm magic header not matched") +) + +// header is the header of a KGM file. +type header struct { + MagicHeader []byte // 0x00-0x0f: magic header + AudioOffset uint32 // 0x10-0x13: offset of audio data + CryptoVersion uint32 // 0x14-0x17: crypto version + CryptoSlot uint32 // 0x18-0x1b: crypto key slot + CryptoTestData []byte // 0x1c-0x2b: crypto test data + CryptoKey []byte // 0x2c-0x3b: crypto key +} + +func (h *header) FromFile(rd io.ReadSeeker) error { + if _, err := rd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("kgm seek start: %w", err) + } + + buf := make([]byte, 0x3c) + if _, err := io.ReadFull(rd, buf); err != nil { + return fmt.Errorf("kgm read header: %w", err) + } + + return h.FromBytes(buf) +} + +func (h *header) FromBytes(buf []byte) error { + if len(buf) < 0x3c { + return errors.New("invalid kgm header length") + } + + h.MagicHeader = buf[:0x10] + if !bytes.Equal(kgmHeader, h.MagicHeader) && !bytes.Equal(vprHeader, h.MagicHeader) { + return ErrKgmMagicHeader + } + + h.AudioOffset = binary.LittleEndian.Uint32(buf[0x10:0x14]) + h.CryptoVersion = binary.LittleEndian.Uint32(buf[0x14:0x18]) + h.CryptoSlot = binary.LittleEndian.Uint32(buf[0x18:0x1c]) + h.CryptoTestData = buf[0x1c:0x2c] + h.CryptoKey = buf[0x2c:0x3c] + + return nil +} diff --git a/decrypt/kgm/kgm_v3.go b/decrypt/kgm/kgm_v3.go new file mode 100644 index 0000000..8296e00 --- /dev/null +++ b/decrypt/kgm/kgm_v3.go @@ -0,0 +1,55 @@ +package kgm + +import ( + "crypto/md5" + "fmt" + + "jsuse.com/dev/dec-music/decrypt" +) + +// kgmCryptoV3 is kgm file crypto v3 +type kgmCryptoV3 struct { + slotBox []byte + fileBox []byte +} + +var kgmV3Slot2Key = map[uint32][]byte{ + 1: {0x6C, 0x2C, 0x2F, 0x27}, +} + +func newKgmCryptoV3(header *header) (decrypt.StreamDecoder, error) { + c := &kgmCryptoV3{} + + slotKey, ok := kgmV3Slot2Key[header.CryptoSlot] + if !ok { + return nil, fmt.Errorf("kgm3: unknown crypto slot %d", header.CryptoSlot) + } + c.slotBox = kugouMD5(slotKey) + + c.fileBox = append(kugouMD5(header.CryptoKey), 0x6b) + + return c, nil +} + +func (d *kgmCryptoV3) Decrypt(b []byte, offset int) { + for i := 0; i < len(b); i++ { + b[i] ^= d.fileBox[(offset+i)%len(d.fileBox)] + b[i] ^= b[i] << 4 + b[i] ^= d.slotBox[(offset+i)%len(d.slotBox)] + b[i] ^= xorCollapseUint32(uint32(offset + i)) + } +} + +func xorCollapseUint32(i uint32) byte { + return byte(i) ^ byte(i>>8) ^ byte(i>>16) ^ byte(i>>24) +} + +func kugouMD5(b []byte) []byte { + digest := md5.Sum(b) + ret := make([]byte, 16) + for i := 0; i < md5.Size; i += 2 { + ret[i] = digest[14-i] + ret[i+1] = digest[14-i+1] + } + return ret +} diff --git a/decrypt/kwm/kwm.go b/decrypt/kwm/kwm.go new file mode 100644 index 0000000..1b1d109 --- /dev/null +++ b/decrypt/kwm/kwm.go @@ -0,0 +1,99 @@ +package kwm + +import ( + "bytes" + "errors" + "fmt" + "io" + "strconv" + "strings" + "unicode" + + "jsuse.com/dev/dec-music/decrypt" +) + +const magicHeader1 = "yeelion-kuwo-tme" +const magicHeader2 = "yeelion-kuwo\x00\x00\x00\x00" +const keyPreDefined = "MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk" + +type decoder struct { + rd io.ReadSeeker + + cipher decrypt.StreamDecoder + offset int + + outputExt string + bitrate int +} + +func (d *decoder) GetAudioExt() string { + return "." + d.outputExt +} + +func newDecoder(p *decrypt.DecoderParams) decrypt.Decoder { + return &decoder{rd: p.Reader} +} + +// Validate checks if the file is a valid Kuwo .kw file. +// rd will be seeked to the beginning of the encrypted audio. +func (d *decoder) Validate() error { + header := make([]byte, 0x400) // kwm header is fixed to 1024 bytes + _, err := io.ReadFull(d.rd, header) + if err != nil { + return fmt.Errorf("kwm read header: %w", err) + } + + // check magic header, 0x00 - 0x0F + magicHeader := header[:0x10] + if !bytes.Equal([]byte(magicHeader1), magicHeader) && + !bytes.Equal([]byte(magicHeader2), magicHeader) { + return errors.New("kwm magic header not matched") + } + + d.cipher = newKwmCipher(header[0x18:0x20]) // Crypto Key, 0x18 - 0x1F + d.bitrate, d.outputExt = parseBitrateAndType(header[0x30:0x38]) // Bitrate & File Extension, 0x30 - 0x38 + + return nil +} + +func parseBitrateAndType(header []byte) (int, string) { + tmp := strings.TrimRight(string(header), "\x00") + sep := strings.IndexFunc(tmp, func(r rune) bool { + return !unicode.IsDigit(r) + }) + + bitrate, _ := strconv.Atoi(tmp[:sep]) // just ignore the error + outputExt := strings.ToLower(tmp[sep:]) + return bitrate, outputExt +} + +func (d *decoder) Read(b []byte) (int, error) { + n, err := d.rd.Read(b) + if n > 0 { + d.cipher.Decrypt(b[:n], d.offset) + d.offset += n + } + return n, err +} + +func padOrTruncate(raw string, length int) string { + lenRaw := len(raw) + out := raw + if lenRaw == 0 { + out = string(make([]byte, length)) + } else if lenRaw > length { + out = raw[:length] + } else if lenRaw < length { + _tmp := make([]byte, 32) + for i := 0; i < 32; i++ { + _tmp[i] = raw[i%lenRaw] + } + out = string(_tmp) + } + return out +} + +func init() { + // Kuwo Mp3/Flac + decrypt.RegisterDecoder("kwm", false, newDecoder) +} diff --git a/decrypt/kwm/kwm_cipher.go b/decrypt/kwm/kwm_cipher.go new file mode 100644 index 0000000..97b8a77 --- /dev/null +++ b/decrypt/kwm/kwm_cipher.go @@ -0,0 +1,31 @@ +package kwm + +import ( + "encoding/binary" + "strconv" +) + +type kwmCipher struct { + mask []byte +} + +func newKwmCipher(key []byte) *kwmCipher { + return &kwmCipher{mask: generateMask(key)} +} + +func generateMask(key []byte) []byte { + keyInt := binary.LittleEndian.Uint64(key) + keyStr := strconv.FormatUint(keyInt, 10) + keyStrTrim := padOrTruncate(keyStr, 32) + mask := make([]byte, 32) + for i := 0; i < 32; i++ { + mask[i] = keyPreDefined[i] ^ keyStrTrim[i] + } + return mask +} + +func (c kwmCipher) Decrypt(buf []byte, offset int) { + for i := range buf { + buf[i] ^= c.mask[(offset+i)&0x1F] // equivalent: [i % 32] + } +} diff --git a/decrypt/ncm/ncm.go b/decrypt/ncm/ncm.go new file mode 100644 index 0000000..b034904 --- /dev/null +++ b/decrypt/ncm/ncm.go @@ -0,0 +1,147 @@ +package ncm + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + + "jsuse.com/dev/dec-music/decrypt" + "jsuse.com/dev/dec-music/internal/crypto" +) + +const magicHeader = "CTENFDAM" + +var ( + keyCore = []byte{ + 0x68, 0x7a, 0x48, 0x52, 0x41, 0x6d, 0x73, 0x6f, + 0x35, 0x6b, 0x49, 0x6e, 0x62, 0x61, 0x78, 0x57, + } + keyMeta = []byte{ + 0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21, + 0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28, + } +) + +func newDecoder(p *decrypt.DecoderParams) decrypt.Decoder { + return &decoder{rd: p.Reader} +} + +type decoder struct { + rd io.ReadSeeker + offset int + cipher decrypt.StreamDecoder +} + +func (d *decoder) Validate() error { + if err := d.validateMagicHeader(); err != nil { + return err + } + + if _, err := d.rd.Seek(2, io.SeekCurrent); err != nil { + return fmt.Errorf("ncm seek file: %w", err) + } + + keyData, err := d.readKeyData() + if err != nil { + return err + } + + if err := d.skipMetaData(); err != nil { + return fmt.Errorf("ncm skip meta: %w", err) + } + + if _, err := d.rd.Seek(5, io.SeekCurrent); err != nil { + return fmt.Errorf("ncm seek gap: %w", err) + } + + if err := d.skipCoverData(); err != nil { + return fmt.Errorf("ncm skip cover: %w", err) + } + + d.cipher = newNcmCipher(keyData) + return nil +} + +func (d *decoder) validateMagicHeader() error { + header := make([]byte, len(magicHeader)) + if _, err := d.rd.Read(header); err != nil { + return fmt.Errorf("ncm read magic header: %w", err) + } + if !bytes.Equal([]byte(magicHeader), header) { + return errors.New("ncm magic header not match") + } + return nil +} + +func (d *decoder) readKeyData() ([]byte, error) { + bKeyLen := make([]byte, 4) + if _, err := io.ReadFull(d.rd, bKeyLen); err != nil { + return nil, fmt.Errorf("ncm read key length: %w", err) + } + iKeyLen := binary.LittleEndian.Uint32(bKeyLen) + + bKeyRaw := make([]byte, iKeyLen) + if _, err := io.ReadFull(d.rd, bKeyRaw); err != nil { + return nil, fmt.Errorf("ncm read key data: %w", err) + } + for i := uint32(0); i < iKeyLen; i++ { + bKeyRaw[i] ^= 0x64 + } + + return crypto.PKCS7UnPadding(crypto.DecryptAES128ECB(bKeyRaw, keyCore))[17:], nil +} + +func (d *decoder) skipMetaData() error { + bMetaLen := make([]byte, 4) + if _, err := io.ReadFull(d.rd, bMetaLen); err != nil { + return fmt.Errorf("ncm read meta length: %w", err) + } + iMetaLen := binary.LittleEndian.Uint32(bMetaLen) + if iMetaLen == 0 { + return nil + } + _, err := d.rd.Seek(int64(iMetaLen), io.SeekCurrent) + return err +} + +func (d *decoder) skipCoverData() error { + bCoverFrameLen := make([]byte, 4) + if _, err := io.ReadFull(d.rd, bCoverFrameLen); err != nil { + return fmt.Errorf("ncm read cover frame length: %w", err) + } + + coverFrameStartOffset, err := d.rd.Seek(0, io.SeekCurrent) + if err != nil { + return err + } + coverFrameLen := binary.LittleEndian.Uint32(bCoverFrameLen) + + bCoverLen := make([]byte, 4) + if _, err := io.ReadFull(d.rd, bCoverLen); err != nil { + return fmt.Errorf("ncm read cover length: %w", err) + } + iCoverLen := binary.LittleEndian.Uint32(bCoverLen) + + if _, err := io.ReadFull(d.rd, make([]byte, iCoverLen)); err != nil { + return fmt.Errorf("ncm skip cover data: %w", err) + } + + offsetAudioData := coverFrameStartOffset + int64(coverFrameLen) + 4 + _, err = d.rd.Seek(offsetAudioData, io.SeekStart) + return err +} + +func (d *decoder) Read(buf []byte) (int, error) { + n, err := d.rd.Read(buf) + if n > 0 { + d.cipher.Decrypt(buf[:n], d.offset) + d.offset += n + } + return n, err +} + +func init() { + decrypt.RegisterDecoder("ncm", false , newDecoder) +} diff --git a/decrypt/ncm/ncm_cipher.go b/decrypt/ncm/ncm_cipher.go new file mode 100644 index 0000000..96a638f --- /dev/null +++ b/decrypt/ncm/ncm_cipher.go @@ -0,0 +1,42 @@ +package ncm + +type ncmCipher struct { + key []byte + box []byte +} + +func newNcmCipher(key []byte) *ncmCipher { + return &ncmCipher{ + key: key, + box: buildKeyBox(key), + } +} + +func (c *ncmCipher) Decrypt(buf []byte, offset int) { + for i := 0; i < len(buf); i++ { + buf[i] ^= c.box[(i+offset)&0xff] + } +} + +func buildKeyBox(key []byte) []byte { + box := make([]byte, 256) + for i := 0; i < 256; i++ { + box[i] = byte(i) + } + + var j byte + for i := 0; i < 256; i++ { + j = box[i] + j + key[i%len(key)] + box[i], box[j] = box[j], box[i] + } + + ret := make([]byte, 256) + var _i byte + for i := 0; i < 256; i++ { + _i = byte(i + 1) + si := box[_i] + sj := box[_i+si] + ret[i] = box[si+sj] + } + return ret +} diff --git a/decrypt/qmc/cipher_map.go b/decrypt/qmc/cipher_map.go new file mode 100644 index 0000000..b73174e --- /dev/null +++ b/decrypt/qmc/cipher_map.go @@ -0,0 +1,39 @@ +package qmc + +import "errors" + +type mapCipher struct { + key []byte + box []byte + size int +} + +func newMapCipher(key []byte) (*mapCipher, error) { + if len(key) == 0 { + return nil, errors.New("qmc/cipher_map: invalid key size") + } + c := &mapCipher{key: key, size: len(key)} + c.box = make([]byte, c.size) + return c, nil +} + +func (c *mapCipher) getMask(offset int) byte { + if offset > 0x7FFF { + offset %= 0x7FFF + } + idx := (offset*offset + 71214) % c.size + return c.rotate(c.key[idx], byte(idx)&0x7) +} + +func (c *mapCipher) rotate(value byte, bits byte) byte { + rotate := (bits + 4) % 8 + left := value << rotate + right := value >> rotate + return left | right +} + +func (c *mapCipher) Decrypt(buf []byte, offset int) { + for i := 0; i < len(buf); i++ { + buf[i] ^= c.getMask(offset + i) + } +} diff --git a/decrypt/qmc/cipher_map_test.go b/decrypt/qmc/cipher_map_test.go new file mode 100644 index 0000000..e478b0d --- /dev/null +++ b/decrypt/qmc/cipher_map_test.go @@ -0,0 +1,53 @@ +package qmc + +import ( + "fmt" + "os" + "reflect" + "testing" +) + +func loadTestDataMapCipher(name string) ([]byte, []byte, []byte, error) { + key, err := os.ReadFile(fmt.Sprintf("./testdata/%s_key.bin", name)) + if err != nil { + return nil, nil, nil, err + } + raw, err := os.ReadFile(fmt.Sprintf("./testdata/%s_raw.bin", name)) + if err != nil { + return nil, nil, nil, err + } + target, err := os.ReadFile(fmt.Sprintf("./testdata/%s_target.bin", name)) + if err != nil { + return nil, nil, nil, err + } + return key, raw, target, nil +} +func Test_mapCipher_Decrypt(t *testing.T) { + + tests := []struct { + name string + wantErr bool + }{ + {"mflac_map", false}, + {"mgg_map", false}, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + key, raw, target, err := loadTestDataMapCipher(tt.name) + if err != nil { + t.Fatalf("load testing data failed: %s", err) + } + c, err := newMapCipher(key) + if err != nil { + t.Errorf("init mapCipher failed: %s", err) + return + } + c.Decrypt(raw, 0) + if !reflect.DeepEqual(raw, target) { + t.Error("overall") + } + }) + } +} diff --git a/decrypt/qmc/cipher_rc4.go b/decrypt/qmc/cipher_rc4.go new file mode 100644 index 0000000..d031d37 --- /dev/null +++ b/decrypt/qmc/cipher_rc4.go @@ -0,0 +1,124 @@ +package qmc + +import ( + "errors" +) + +// A rc4Cipher is an instance of RC4 using a particular key. +type rc4Cipher struct { + box []byte + key []byte + hash uint32 + n int +} + +// newRC4Cipher creates and returns a new rc4Cipher. The key argument should be the +// RC4 key, at least 1 byte and at most 256 bytes. +func newRC4Cipher(key []byte) (*rc4Cipher, error) { + n := len(key) + if n == 0 { + return nil, errors.New("qmc/cipher_rc4: invalid key size") + } + + var c = rc4Cipher{key: key, n: n} + c.box = make([]byte, n) + + for i := 0; i < n; i++ { + c.box[i] = byte(i) + } + + var j = 0 + for i := 0; i < n; i++ { + j = (j + int(c.box[i]) + int(key[i%n])) % n + c.box[i], c.box[j] = c.box[j], c.box[i] + } + c.getHashBase() + return &c, nil +} + +func (c *rc4Cipher) getHashBase() { + c.hash = 1 + for i := 0; i < c.n; i++ { + v := uint32(c.key[i]) + if v == 0 { + continue + } + nextHash := c.hash * v + if nextHash == 0 || nextHash <= c.hash { + break + } + c.hash = nextHash + } +} + +const ( + rc4SegmentSize = 5120 + rc4FirstSegmentSize = 128 +) + +func (c *rc4Cipher) Decrypt(src []byte, offset int) { + toProcess := len(src) + processed := 0 + markProcess := func(p int) (finished bool) { + offset += p + toProcess -= p + processed += p + return toProcess == 0 + } + + if offset < rc4FirstSegmentSize { + blockSize := toProcess + if blockSize > rc4FirstSegmentSize-offset { + blockSize = rc4FirstSegmentSize - offset + } + c.encFirstSegment(src[:blockSize], offset) + if markProcess(blockSize) { + return + } + } + + if offset%rc4SegmentSize != 0 { + blockSize := toProcess + if blockSize > rc4SegmentSize-offset%rc4SegmentSize { + blockSize = rc4SegmentSize - offset%rc4SegmentSize + } + c.encASegment(src[processed:processed+blockSize], offset) + if markProcess(blockSize) { + return + } + } + for toProcess > rc4SegmentSize { + c.encASegment(src[processed:processed+rc4SegmentSize], offset) + markProcess(rc4SegmentSize) + } + + if toProcess > 0 { + c.encASegment(src[processed:], offset) + } +} +func (c *rc4Cipher) encFirstSegment(buf []byte, offset int) { + for i := 0; i < len(buf); i++ { + buf[i] ^= c.key[c.getSegmentSkip(offset+i)] + } +} + +func (c *rc4Cipher) encASegment(buf []byte, offset int) { + box := make([]byte, c.n) + copy(box, c.box) + j, k := 0, 0 + + skipLen := (offset % rc4SegmentSize) + c.getSegmentSkip(offset/rc4SegmentSize) + for i := -skipLen; i < len(buf); i++ { + j = (j + 1) % c.n + k = (int(box[j]) + k) % c.n + box[j], box[k] = box[k], box[j] + if i >= 0 { + buf[i] ^= box[(int(box[j])+int(box[k]))%c.n] + } + } +} +func (c *rc4Cipher) getSegmentSkip(id int) int { + seed := int(c.key[id%c.n]) + idx := int64(float64(c.hash) / float64((id+1)*seed) * 100.0) + return int(idx % int64(c.n)) +} diff --git a/decrypt/qmc/cipher_rc4_test.go b/decrypt/qmc/cipher_rc4_test.go new file mode 100644 index 0000000..5f2df92 --- /dev/null +++ b/decrypt/qmc/cipher_rc4_test.go @@ -0,0 +1,115 @@ +package qmc + +import ( + "os" + "reflect" + "testing" +) + +func loadTestRC4CipherData(name string) ([]byte, []byte, []byte, error) { + prefix := "./testdata/" + name + key, err := os.ReadFile(prefix + "_key.bin") + if err != nil { + return nil, nil, nil, err + } + raw, err := os.ReadFile(prefix + "_raw.bin") + if err != nil { + return nil, nil, nil, err + } + target, err := os.ReadFile(prefix + "_target.bin") + if err != nil { + return nil, nil, nil, err + } + + return key, raw, target, nil +} +func Test_rc4Cipher_Decrypt(t *testing.T) { + tests := []struct { + name string + wantErr bool + }{ + {"mflac0_rc4", false}, + {"mflac_rc4", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, raw, target, err := loadTestRC4CipherData(tt.name) + if err != nil { + t.Fatalf("load testing data failed: %s", err) + } + c, err := newRC4Cipher(key) + if err != nil { + t.Errorf("init rc4Cipher failed: %s", err) + return + } + c.Decrypt(raw, 0) + if !reflect.DeepEqual(raw, target) { + t.Error("overall") + } + }) + } + +} +func BenchmarkRc4Cipher_Decrypt(b *testing.B) { + key, raw, _, err := loadTestRC4CipherData("mflac0_rc4") + if err != nil { + b.Fatalf("load testing data failed: %s", err) + } + c, err := newRC4Cipher(key) + if err != nil { + b.Errorf("init rc4Cipher failed: %s", err) + return + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Decrypt(raw, 0) + } +} + +func Test_rc4Cipher_encFirstSegment(t *testing.T) { + key, raw, target, err := loadTestRC4CipherData("mflac0_rc4") + if err != nil { + t.Fatalf("load testing data failed: %s", err) + } + t.Run("first-block(0~128)", func(t *testing.T) { + c, err := newRC4Cipher(key) + if err != nil { + t.Errorf("init rc4Cipher failed: %s", err) + return + } + c.Decrypt(raw[:128], 0) + if !reflect.DeepEqual(raw[:128], target[:128]) { + t.Error("first-block(0~128)") + } + }) +} + +func Test_rc4Cipher_encASegment(t *testing.T) { + key, raw, target, err := loadTestRC4CipherData("mflac0_rc4") + if err != nil { + t.Fatalf("load testing data failed: %s", err) + } + + t.Run("align-block(128~5120)", func(t *testing.T) { + c, err := newRC4Cipher(key) + if err != nil { + t.Errorf("init rc4Cipher failed: %s", err) + return + } + c.Decrypt(raw[128:5120], 128) + if !reflect.DeepEqual(raw[128:5120], target[128:5120]) { + t.Error("align-block(128~5120)") + } + }) + t.Run("simple-block(5120~10240)", func(t *testing.T) { + c, err := newRC4Cipher(key) + if err != nil { + t.Errorf("init rc4Cipher failed: %s", err) + return + } + c.Decrypt(raw[5120:10240], 5120) + if !reflect.DeepEqual(raw[5120:10240], target[5120:10240]) { + t.Error("align-block(128~5120)") + } + }) +} diff --git a/decrypt/qmc/cipher_static.go b/decrypt/qmc/cipher_static.go new file mode 100644 index 0000000..318e191 --- /dev/null +++ b/decrypt/qmc/cipher_static.go @@ -0,0 +1,57 @@ +package qmc + +func newStaticCipher() *staticCipher { + return &defaultStaticCipher +} + +var defaultStaticCipher = staticCipher{} + +type staticCipher struct{} + +func (c *staticCipher) Decrypt(buf []byte, offset int) { + for i := 0; i < len(buf); i++ { + buf[i] ^= c.getMask(offset + i) + } +} +func (c *staticCipher) getMask(offset int) byte { + if offset > 0x7FFF { + offset %= 0x7FFF + } + idx := (offset*offset + 27) & 0xff + return staticCipherBox[idx] +} + +var staticCipherBox = [...]byte{ + 0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00 + 0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08 + 0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, //0x10 + 0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6, //0x18 + 0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, //0x20 + 0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0, //0x28 + 0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, //0x30 + 0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7, //0x38 + 0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, //0x40 + 0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43, //0x48 + 0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, //0x50 + 0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95, //0x58 + 0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, //0x60 + 0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC, //0x68 + 0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, //0x70 + 0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66, //0x78 + 0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, //0x80 + 0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37, //0x88 + 0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, //0x90 + 0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74, //0x98 + 0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, //0xA0 + 0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95, //0xA8 + 0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, //0xB0 + 0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F, //0xB8 + 0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, //0xC0 + 0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16, //0xC8 + 0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, //0xD0 + 0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09, //0xD8 + 0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, //0xE0 + 0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76, //0xE8 + 0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, //0xF0 + 0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11, //0xF8 +} diff --git a/decrypt/qmc/key_derive.go b/decrypt/qmc/key_derive.go new file mode 100644 index 0000000..09961d4 --- /dev/null +++ b/decrypt/qmc/key_derive.go @@ -0,0 +1,161 @@ +package qmc + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "math" + + "jsuse.com/dev/dec-music/internal/tea" +) + +func simpleMakeKey(salt byte, length int) []byte { + keyBuf := make([]byte, length) + for i := 0; i < length; i++ { + tmp := math.Tan(float64(salt) + float64(i)*0.1) + keyBuf[i] = byte(math.Abs(tmp) * 100.0) + } + return keyBuf +} + +const rawKeyPrefixV2 = "QQMusic EncV2,Key:" + +func deriveKey(rawKey []byte) ([]byte, error) { + rawKeyDec := make([]byte, base64.StdEncoding.DecodedLen(len(rawKey))) + n, err := base64.StdEncoding.Decode(rawKeyDec, rawKey) + if err != nil { + return nil, err + } + rawKeyDec = rawKeyDec[:n] + + if bytes.HasPrefix(rawKeyDec, []byte(rawKeyPrefixV2)) { + rawKeyDec, err = deriveKeyV2(bytes.TrimPrefix(rawKeyDec, []byte(rawKeyPrefixV2))) + if err != nil { + return nil, fmt.Errorf("deriveKeyV2 failed: %w", err) + } + } + return deriveKeyV1(rawKeyDec) +} + +func deriveKeyV1(rawKeyDec []byte) ([]byte, error) { + if len(rawKeyDec) < 16 { + return nil, errors.New("key length is too short") + } + + simpleKey := simpleMakeKey(106, 8) + teaKey := make([]byte, 16) + for i := 0; i < 8; i++ { + teaKey[i<<1] = simpleKey[i] + teaKey[i<<1+1] = rawKeyDec[i] + } + + rs, err := decryptTencentTea(rawKeyDec[8:], teaKey) + if err != nil { + return nil, err + } + return append(rawKeyDec[:8], rs...), nil +} + +var ( + deriveV2Key1 = []byte{ + 0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40, + 0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28, + } + + deriveV2Key2 = []byte{ + 0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25, + 0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54, + } +) + +func deriveKeyV2(raw []byte) ([]byte, error) { + buf, err := decryptTencentTea(raw, deriveV2Key1) + if err != nil { + return nil, err + } + + buf, err = decryptTencentTea(buf, deriveV2Key2) + if err != nil { + return nil, err + } + + n, err := base64.StdEncoding.Decode(buf, buf) + if err != nil { + return nil, err + } + return buf[:n], nil +} + +func decryptTencentTea(inBuf []byte, key []byte) ([]byte, error) { + const saltLen = 2 + const zeroLen = 7 + if len(inBuf)%8 != 0 { + return nil, errors.New("inBuf size not a multiple of the block size") + } + if len(inBuf) < 16 { + return nil, errors.New("inBuf size too small") + } + + blk, err := tea.NewCipherWithRounds(key, 32) + if err != nil { + return nil, err + } + + destBuf := make([]byte, 8) + blk.Decrypt(destBuf, inBuf) + padLen := int(destBuf[0] & 0x7) + outLen := len(inBuf) - 1 - padLen - saltLen - zeroLen + + out := make([]byte, outLen) + + ivPrev := make([]byte, 8) + ivCur := inBuf[:8] + + inBufPos := 8 + + destIdx := 1 + padLen + cryptBlock := func() { + ivPrev = ivCur + ivCur = inBuf[inBufPos : inBufPos+8] + + xor8Bytes(destBuf, destBuf, inBuf[inBufPos:inBufPos+8]) + blk.Decrypt(destBuf, destBuf) + + inBufPos += 8 + destIdx = 0 + } + for i := 1; i <= saltLen; { + if destIdx < 8 { + destIdx++ + i++ + } else if destIdx == 8 { + cryptBlock() + } + } + + outPos := 0 + for outPos < outLen { + if destIdx < 8 { + out[outPos] = destBuf[destIdx] ^ ivPrev[destIdx] + destIdx++ + outPos++ + } else if destIdx == 8 { + cryptBlock() + } + } + + for i := 1; i <= zeroLen; i++ { + if destBuf[destIdx] != ivPrev[destIdx] { + return nil, errors.New("zero check failed") + } + } + + return out, nil +} + +func xor8Bytes(dst, a, b []byte) { + for i := 0; i < 8; i++ { + dst[i] = a[i] ^ b[i] + } +} diff --git a/decrypt/qmc/key_derive_test.go b/decrypt/qmc/key_derive_test.go new file mode 100644 index 0000000..b401692 --- /dev/null +++ b/decrypt/qmc/key_derive_test.go @@ -0,0 +1,57 @@ +package qmc + +import ( + "fmt" + "os" + "reflect" + "testing" +) + +func TestSimpleMakeKey(t *testing.T) { + expect := []byte{0x69, 0x56, 0x46, 0x38, 0x2b, 0x20, 0x15, 0x0b} + t.Run("106,8", func(t *testing.T) { + if got := simpleMakeKey(106, 8); !reflect.DeepEqual(got, expect) { + t.Errorf("simpleMakeKey() = %v, want %v", got, expect) + } + }) +} +func loadDecryptKeyData(name string) ([]byte, []byte, error) { + keyRaw, err := os.ReadFile(fmt.Sprintf("./testdata/%s_key_raw.bin", name)) + if err != nil { + return nil, nil, err + } + keyDec, err := os.ReadFile(fmt.Sprintf("./testdata/%s_key.bin", name)) + if err != nil { + return nil, nil, err + } + return keyRaw, keyDec, nil +} +func TestDecryptKey(t *testing.T) { + tests := []struct { + name string + filename string + wantErr bool + }{ + {"mflac0_rc4(512)", "mflac0_rc4", false}, + {"mflac_map(256)", "mflac_map", false}, + {"mflac_rc4(256)", "mflac_rc4", false}, + {"mgg_map(256)", "mgg_map", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, want, err := loadDecryptKeyData(tt.filename) + if err != nil { + t.Fatalf("load test data failed: %s", err) + } + got, err := deriveKey(raw) + if (err != nil) != tt.wantErr { + t.Errorf("deriveKey() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, want) { + t.Errorf("deriveKey() got = %v..., want %v...", + string(got[:32]), string(want[:32])) + } + }) + } +} diff --git a/decrypt/qmc/key_mmkv.go b/decrypt/qmc/key_mmkv.go new file mode 100644 index 0000000..8397938 --- /dev/null +++ b/decrypt/qmc/key_mmkv.go @@ -0,0 +1,158 @@ +package qmc + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "unicode/utf8" + + "jsuse.com/dev/dec-music/log" + "jsuse.com/dev/dec-music/mmkv" +) + +var streamKeyVault mmkv.Vault + +func readKeyFromMMKV(file string, logger *log.Logger) ([]byte, error) { + if file == "" { + return nil, errors.New("file path is required while reading key from mmkv") + } + if runtime.GOOS != "darwin" { + return nil, errors.New("mmkv vault not supported on this platform") + } + + if streamKeyVault == nil { + mmkvDir, err := getRelativeMMKVDir(file) + if err != nil { + mmkvDir, err = getDefaultMMKVDir() + if err != nil { + return nil, fmt.Errorf("mmkv key vault not found: %w", err) + } + } + + mgr, err := mmkv.NewManager(mmkvDir) + if err != nil { + return nil, fmt.Errorf("init mmkv manager: %w", err) + } + + streamKeyVault, err = mgr.OpenVault("MMKVStreamEncryptId") + if err != nil { + return nil, fmt.Errorf("open mmkv vault: %w", err) + } + + if logger != nil { + logger.Debug("mmkv vault opened, keys:", len(streamKeyVault.Keys())) + } + } + + _, partName := filepath.Split(file) + partName = normalizeUnicode(partName) + + buf, err := streamKeyVault.GetBytes(file) + if buf == nil { + filePaths := streamKeyVault.Keys() + fileNames := make([]string, len(filePaths)) + for i, filePath := range filePaths { + _, name := filepath.Split(filePath) + fileNames[i] = normalizeUnicode(name) + } + + for i, key := range fileNames { + if key != partName { + continue + } + buf, err = streamKeyVault.GetBytes(filePaths[i]) + if err != nil && logger != nil { + logger.Warn("read key from mmkv", filePaths[i], err) + } + break + } + } + + if len(buf) == 0 { + return nil, errors.New("key not found in mmkv vault") + } + return deriveKey(buf) +} + +// OpenMMKVCLI opens a QQ Music MMKV vault (used by the CLI). +func OpenMMKVCLI(mmkvPath, key string, logger *log.Logger) error { + return openMMKV(mmkvPath, key, logger) +} + +func openMMKV(mmkvPath, key string, logger *log.Logger) error { + filePath, fileName := filepath.Split(mmkvPath) + mgr, err := mmkv.NewManager(filepath.Dir(filePath)) + if err != nil { + return fmt.Errorf("init mmkv manager: %w", err) + } + + streamKeyVault, err = mgr.OpenVaultCrypto(fileName, key) + if err != nil { + return fmt.Errorf("open mmkv vault: %w", err) + } + if logger != nil { + logger.Debug("mmkv vault opened, keys:", len(streamKeyVault.Keys())) + } + return nil +} + +func readKeyFromMMKVCustom(mid string) ([]byte, error) { + if streamKeyVault == nil { + return nil, fmt.Errorf("mmkv vault not loaded") + } + eKey, err := streamKeyVault.GetBytes(mid) + if err != nil { + return nil, fmt.Errorf("get eKey error: %w", err) + } + return deriveKey(eKey) +} + +func getRelativeMMKVDir(file string) (string, error) { + mmkvDir := filepath.Join(filepath.Dir(file), "../mmkv") + if _, err := os.Stat(mmkvDir); err != nil { + return "", err + } + keyFile := filepath.Join(mmkvDir, "MMKVStreamEncryptId") + if _, err := os.Stat(keyFile); err != nil { + return "", err + } + return mmkvDir, nil +} + +func getDefaultMMKVDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + mmkvDir := filepath.Join( + homeDir, + "Library/Containers/com.tencent.QQMusicMac/Data", + "Library/Application Support/QQMusicMac/mmkv", + ) + if _, err := os.Stat(mmkvDir); err != nil { + return "", err + } + keyFile := filepath.Join(mmkvDir, "MMKVStreamEncryptId") + if _, err := os.Stat(keyFile); err != nil { + return "", err + } + return mmkvDir, nil +} + +// normalizeUnicode applies a simple NFC-like fix for macOS decomposed filenames. +func normalizeUnicode(str string) string { + if utf8.ValidString(str) && !strings.ContainsRune(str, '\u0300') { + return str + } + var b strings.Builder + for _, r := range str { + if r >= 0x0300 && r <= 0x036F { + continue + } + b.WriteRune(r) + } + return b.String() +} diff --git a/decrypt/qmc/qmc.go b/decrypt/qmc/qmc.go new file mode 100644 index 0000000..901381b --- /dev/null +++ b/decrypt/qmc/qmc.go @@ -0,0 +1,198 @@ +package qmc + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "runtime" + "strings" + + "jsuse.com/dev/dec-music/decrypt" + "jsuse.com/dev/dec-music/internal/sniff" + "jsuse.com/dev/dec-music/log" +) + +type decoder struct { + raw io.ReadSeeker + params *decrypt.DecoderParams + + audio io.Reader + audioLen int + offset int + + decodedKey []byte + cipher decrypt.StreamDecoder + + logger *log.Logger +} + +func newDecoder(p *decrypt.DecoderParams) decrypt.Decoder { + return &decoder{raw: p.Reader, params: p, logger: p.Logger} +} + +func (d *decoder) Read(p []byte) (int, error) { + n, err := d.audio.Read(p) + if n > 0 { + d.cipher.Decrypt(p[:n], d.offset) + d.offset += n + } + return n, err +} + +func (d *decoder) Validate() error { + if err := d.searchKey(); err != nil { + return err + } + + var err error + if len(d.decodedKey) > 300 { + d.cipher, err = newRC4Cipher(d.decodedKey) + } else if len(d.decodedKey) != 0 { + d.cipher, err = newMapCipher(d.decodedKey) + } else { + d.cipher = newStaticCipher() + } + if err != nil { + return err + } + + if err := d.validateDecode(); err != nil { + return err + } + + if _, err := d.raw.Seek(0, io.SeekStart); err != nil { + return err + } + d.audio = io.LimitReader(d.raw, int64(d.audioLen)) + return nil +} + +func (d *decoder) validateDecode() error { + if _, err := d.raw.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("qmc seek to start: %w", err) + } + + buf := make([]byte, 64) + if _, err := io.ReadFull(d.raw, buf); err != nil { + return fmt.Errorf("qmc read header: %w", err) + } + + d.cipher.Decrypt(buf, 0) + if _, ok := sniff.AudioExtension(buf); !ok { + return errors.New("qmc: detect file type failed") + } + return nil +} + +func (d *decoder) searchKey() (err error) { + fileSizeM4, err := d.raw.Seek(-4, io.SeekEnd) + if err != nil { + return err + } + fileSize := int(fileSizeM4) + 4 + + if runtime.GOOS == "darwin" && !strings.HasPrefix(d.params.Extension, ".qmc") { + d.decodedKey, err = readKeyFromMMKV(d.params.FilePath, d.logger) + if err == nil { + d.audioLen = fileSize + return nil + } + if d.logger != nil { + d.logger.Warn("read key from mmkv failed:", err) + } + } + + suffixBuf := make([]byte, 4) + if _, err := io.ReadFull(d.raw, suffixBuf); err != nil { + return err + } + + switch string(suffixBuf) { + case "QTag": + return d.readRawMetaQTag() + case "STag": + return errors.New("qmc: file with 'STag' suffix doesn't contains media key") + case "cex\x00": + footer, err := newMusicExTag(d.raw) + if err != nil { + return err + } + d.audioLen = fileSize - int(footer.tagSize) + d.decodedKey, err = readKeyFromMMKVCustom(footer.mediaFileName) + return err + default: + size := binary.LittleEndian.Uint32(suffixBuf) + if size <= 0xFFFF && size != 0 { + return d.readRawKey(int64(size)) + } + d.audioLen = fileSize + return nil + } +} + +func (d *decoder) readRawKey(rawKeyLen int64) error { + audioLen, err := d.raw.Seek(-(4 + rawKeyLen), io.SeekEnd) + if err != nil { + return err + } + d.audioLen = int(audioLen) + + rawKeyData, err := io.ReadAll(io.LimitReader(d.raw, rawKeyLen)) + if err != nil { + return err + } + rawKeyData = bytes.TrimRight(rawKeyData, "\x00") + + d.decodedKey, err = deriveKey(rawKeyData) + return err +} + +func (d *decoder) readRawMetaQTag() error { + if _, err := d.raw.Seek(-8, io.SeekEnd); err != nil { + return err + } + buf, err := io.ReadAll(io.LimitReader(d.raw, 4)) + if err != nil { + return err + } + rawMetaLen := int64(binary.BigEndian.Uint32(buf)) + + audioLen, err := d.raw.Seek(-(8 + rawMetaLen), io.SeekEnd) + if err != nil { + return err + } + d.audioLen = int(audioLen) + rawMetaData, err := io.ReadAll(io.LimitReader(d.raw, rawMetaLen)) + if err != nil { + return err + } + + items := strings.Split(string(rawMetaData), ",") + if len(items) != 3 { + return errors.New("invalid raw meta data") + } + + d.decodedKey, err = deriveKey([]byte(items[0])) + return err +} + +func init() { + supportedExts := []string{ + "qmc0", "qmc3", "qmc2", "qmc4", "qmc6", "qmc8", + "qmcflac", "qmcogg", "tkm", + "bkcmp3", "bkcm4a", "bkcflac", "bkcwav", "bkcape", "bkcogg", "bkcwma", + "666c6163", "6d7033", "6f6767", "6d3461", "776176", "mmp4", + } + for _, ext := range supportedExts { + decrypt.RegisterDecoder(ext, false , newDecoder) + } + + for _, ext := range []string{"mgg", "mflac"} { + decrypt.RegisterDecoder(ext, false , newDecoder) + for _, suffix := range []string{"0", "1", "a", "h", "l"} { + decrypt.RegisterDecoder(ext+suffix, false , newDecoder) + } + } +} diff --git a/decrypt/qmc/qmc_footer_musicex.go b/decrypt/qmc/qmc_footer_musicex.go new file mode 100644 index 0000000..99389d0 --- /dev/null +++ b/decrypt/qmc/qmc_footer_musicex.go @@ -0,0 +1,90 @@ +package qmc + +import ( + bytes "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "strings" +) + +type musicExTagV1 struct { + songID uint32 + unknown1 uint32 + unknown2 uint32 + mediaID string + mediaFileName string + unknown3 uint32 + tagSize uint32 + tagVersion uint32 + tagMagic []byte +} + +func newMusicExTag(f io.ReadSeeker) (*musicExTagV1, error) { + _, err := f.Seek(-16, io.SeekEnd) + if err != nil { + return nil, fmt.Errorf("musicex seek error: %w", err) + } + + buffer := make([]byte, 16) + bytesRead, err := f.Read(buffer) + if err != nil { + return nil, fmt.Errorf("get musicex error: %w", err) + } + if bytesRead != 16 { + return nil, fmt.Errorf("MusicExV1: read %d bytes (expected %d)", bytesRead, 16) + } + + tag := &musicExTagV1{ + tagSize: binary.LittleEndian.Uint32(buffer[0x00:0x04]), + tagVersion: binary.LittleEndian.Uint32(buffer[0x04:0x08]), + tagMagic: buffer[0x08:0x10], + } + + if !bytes.Equal(tag.tagMagic, []byte("musicex\x00")) { + return nil, errors.New("MusicEx magic mismatch") + } + if tag.tagVersion != 1 { + return nil, fmt.Errorf("unsupported musicex tag version: %d", tag.tagVersion) + } + + if tag.tagSize < 0xC0 { + return nil, fmt.Errorf("unsupported musicex tag size: 0x%x", tag.tagSize) + } + + buffer = make([]byte, tag.tagSize) + bytesRead, err = f.Read(buffer) + if err != nil { + return nil, err + } + if uint32(bytesRead) != tag.tagSize { + return nil, fmt.Errorf("MusicExV1: read %d bytes (expected %d)", bytesRead, tag.tagSize) + } + + tag.songID = binary.LittleEndian.Uint32(buffer[0x00:0x04]) + tag.unknown1 = binary.LittleEndian.Uint32(buffer[0x04:0x08]) + tag.unknown2 = binary.LittleEndian.Uint32(buffer[0x08:0x0C]) + tag.mediaID = readUnicodeTagName(buffer[0x0C:], 30*2) + tag.mediaFileName = readUnicodeTagName(buffer[0x48:], 50*2) + tag.unknown3 = binary.LittleEndian.Uint32(buffer[0xAC:0xB0]) + return tag, nil +} + +// readUnicodeTagName reads a buffer to maxLen. +// reconstruct text by skipping alternate char (ascii chars encoded in UTF-16-LE), +// until finding a zero or reaching maxLen. +func readUnicodeTagName(buffer []byte, maxLen int) string { + builder := strings.Builder{} + + for i := 0; i < maxLen; i += 2 { + chr := buffer[i] + if chr != 0 { + builder.WriteByte(chr) + } else { + break + } + } + + return builder.String() +} diff --git a/decrypt/qmc/qmc_test.go b/decrypt/qmc/qmc_test.go new file mode 100644 index 0000000..23e1723 --- /dev/null +++ b/decrypt/qmc/qmc_test.go @@ -0,0 +1,101 @@ +package qmc + +import ( + "bytes" + "fmt" + "io" + "os" + "reflect" + "testing" + + "jsuse.com/dev/dec-music/decrypt" +) + +func loadTestDataQmcDecoder(filename string) ([]byte, []byte, error) { + encBody, err := os.ReadFile(fmt.Sprintf("./testdata/%s_raw.bin", filename)) + if err != nil { + return nil, nil, err + } + encSuffix, err := os.ReadFile(fmt.Sprintf("./testdata/%s_suffix.bin", filename)) + if err != nil { + return nil, nil, err + } + + target, err := os.ReadFile(fmt.Sprintf("./testdata/%s_target.bin", filename)) + if err != nil { + return nil, nil, err + } + return bytes.Join([][]byte{encBody, encSuffix}, nil), target, nil + +} +func TestMflac0Decoder_Read(t *testing.T) { + tests := []struct { + name string + fileExt string + wantErr bool + }{ + {"mflac0_rc4", ".mflac0", false}, + {"mflac_rc4", ".mflac", false}, + {"mflac_map", ".mflac", false}, + {"mgg_map", ".mgg", false}, + {"qmc0_static", ".qmc0", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, target, err := loadTestDataQmcDecoder(tt.name) + if err != nil { + t.Fatal(err) + } + + d := newDecoder(&decrypt.DecoderParams{ + Reader: bytes.NewReader(raw), + Extension: tt.fileExt, + }) + if err := d.Validate(); err != nil { + t.Errorf("validate file error = %v", err) + } + + buf := make([]byte, len(target)) + if _, err := io.ReadFull(d, buf); err != nil { + t.Errorf("read bytes from decoder error = %v", err) + return + } + if !reflect.DeepEqual(buf, target) { + t.Errorf("Decrypt() got = %v, want %v", buf[:32], target[:32]) + } + }) + } + +} + +func TestMflac0Decoder_Validate(t *testing.T) { + tests := []struct { + name string + fileExt string + wantErr bool + }{ + {"mflac0_rc4", ".flac", false}, + {"mflac_map", ".flac", false}, + {"mgg_map", ".ogg", false}, + {"qmc0_static", ".mp3", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, _, err := loadTestDataQmcDecoder(tt.name) + if err != nil { + t.Fatal(err) + } + d := newDecoder(&decrypt.DecoderParams{ + Reader: bytes.NewReader(raw), + Extension: tt.fileExt, + }) + + if err := d.Validate(); err != nil { + t.Errorf("read bytes from decoder error = %v", err) + return + } + }) + } +} diff --git a/decrypt/raw.go b/decrypt/raw.go new file mode 100644 index 0000000..29dc135 --- /dev/null +++ b/decrypt/raw.go @@ -0,0 +1,50 @@ +package decrypt + +import ( + "errors" + "fmt" + "io" + + "jsuse.com/dev/dec-music/internal/sniff" +) + +type rawDecoder struct { + rd io.ReadSeeker + + audioExt string +} + +func newRawDecoder(p *DecoderParams) Decoder { + return &rawDecoder{rd: p.Reader} +} + +func (d *rawDecoder) Validate() error { + header := make([]byte, 16) + if _, err := io.ReadFull(d.rd, header); err != nil { + return fmt.Errorf("read file header failed: %v", err) + } + if _, err := d.rd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek file failed: %v", err) + } + + var ok bool + d.audioExt, ok = sniff.AudioExtension(header) + if !ok { + return errors.New("raw: sniff audio type failed") + } + return nil +} + +func (d *rawDecoder) Read(p []byte) (n int, err error) { + return d.rd.Read(p) +} + +func init() { + RegisterDecoder("mp3", true, newRawDecoder) + RegisterDecoder("flac", true, newRawDecoder) + RegisterDecoder("ogg", true, newRawDecoder) + RegisterDecoder("m4a", true, newRawDecoder) + RegisterDecoder("wav", true, newRawDecoder) + RegisterDecoder("wma", true, newRawDecoder) + RegisterDecoder("aac", true, newRawDecoder) +} diff --git a/decrypt/tm/tm.go b/decrypt/tm/tm.go new file mode 100644 index 0000000..44d0a5d --- /dev/null +++ b/decrypt/tm/tm.go @@ -0,0 +1,56 @@ +package tm + +import ( + "bytes" + "errors" + "fmt" + "io" + + "jsuse.com/dev/dec-music/decrypt" + "jsuse.com/dev/dec-music/internal/sniff" +) + +var replaceHeader = []byte{0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70} +var magicHeader = []byte{0x51, 0x51, 0x4D, 0x55} //0x15, 0x1D, 0x1A, 0x21 + +type decoder struct { + raw io.ReadSeeker // raw is the original file reader + + offset int + audio io.Reader // audio is the decrypted audio data +} + +func (d *decoder) Validate() error { + header := make([]byte, 8) + if _, err := io.ReadFull(d.raw, header); err != nil { + return fmt.Errorf("tm read header: %w", err) + } + + if bytes.Equal(magicHeader, header[:len(magicHeader)]) { // replace m4a header + d.audio = io.MultiReader(bytes.NewReader(replaceHeader), d.raw) + return nil + } + + if _, ok := sniff.AudioExtension(header); ok { // not encrypted + d.audio = io.MultiReader(bytes.NewReader(header), d.raw) + return nil + } + + return errors.New("tm: valid magic header") +} + +func (d *decoder) Read(buf []byte) (int, error) { + return d.audio.Read(buf) +} + +func newTmDecoder(p *decrypt.DecoderParams) decrypt.Decoder { + return &decoder{raw: p.Reader} +} + +func init() { + // QQ Music IOS M4a (replace header) + decrypt.RegisterDecoder("tm2", false, newTmDecoder) + decrypt.RegisterDecoder("tm6", false, newTmDecoder) + decrypt.RegisterDecoder("tm0", false, newTmDecoder) + decrypt.RegisterDecoder("tm3", false, newTmDecoder) +} diff --git a/decrypt/xiami/xm.go b/decrypt/xiami/xm.go new file mode 100644 index 0000000..df94fc2 --- /dev/null +++ b/decrypt/xiami/xm.go @@ -0,0 +1,91 @@ +package xiami + +import ( + "bytes" + "errors" + "fmt" + "io" + + "jsuse.com/dev/dec-music/decrypt" +) + +var ( + magicHeader = []byte{'i', 'f', 'm', 't'} + magicHeader2 = []byte{0xfe, 0xfe, 0xfe, 0xfe} + typeMapping = map[string]string{ + " WAV": "wav", + "FLAC": "flac", + " MP3": "mp3", + " A4M": "m4a", + } + ErrMagicHeader = errors.New("xm magic header not matched") +) + +type decoder struct { + rd io.ReadSeeker // rd is the original file reader + offset int + + cipher decrypt.StreamDecoder + outputExt string +} + +func (d *decoder) GetAudioExt() string { + if d.outputExt != "" { + return "." + d.outputExt + + } + return "" +} + +func newDecoder(p *decrypt.DecoderParams) decrypt.Decoder { + return &decoder{rd: p.Reader} +} + +// Validate checks if the file is a valid xiami .xm file. +// rd will set to the beginning of the encrypted audio data. +func (d *decoder) Validate() error { + header := make([]byte, 16) // xm header is fixed to 16 bytes + + if _, err := io.ReadFull(d.rd, header); err != nil { + return fmt.Errorf("xm read header: %w", err) + } + + // 0x00 - 0x03 and 0x08 - 0x0B: magic header + if !bytes.Equal(magicHeader, header[:4]) || !bytes.Equal(magicHeader2, header[8:12]) { + return ErrMagicHeader + } + + // 0x04 - 0x07: Audio File Type + var ok bool + d.outputExt, ok = typeMapping[string(header[4:8])] + if !ok { + return fmt.Errorf("xm detect unknown audio type: %s", string(header[4:8])) + } + + // 0x0C - 0x0E, Encrypt Start At, LittleEndian Unit24 + encStartAt := uint32(header[12]) | uint32(header[13])<<8 | uint32(header[14])<<16 + + // 0x0F, XOR Mask + d.cipher = newXmCipher(header[15], int(encStartAt)) + + return nil +} + +func (d *decoder) Read(p []byte) (int, error) { + n, err := d.rd.Read(p) + if n > 0 { + d.cipher.Decrypt(p[:n], d.offset) + d.offset += n + } + return n, err +} + +func init() { + // Xiami Wav/M4a/Mp3/Flac + decrypt.RegisterDecoder("xm", false , newDecoder) + // Xiami Typed Format + decrypt.RegisterDecoder("wav", false , newDecoder) + decrypt.RegisterDecoder("mp3", false , newDecoder) + decrypt.RegisterDecoder("flac", false , newDecoder) + decrypt.RegisterDecoder("m4a", false , newDecoder) +} diff --git a/decrypt/xiami/xm_cipher.go b/decrypt/xiami/xm_cipher.go new file mode 100644 index 0000000..7f1e93c --- /dev/null +++ b/decrypt/xiami/xm_cipher.go @@ -0,0 +1,21 @@ +package xiami + +type xmCipher struct { + mask byte + encryptStartAt int +} + +func newXmCipher(mask byte, encryptStartAt int) *xmCipher { + return &xmCipher{ + mask: mask, + encryptStartAt: encryptStartAt, + } +} + +func (c *xmCipher) Decrypt(buf []byte, offset int) { + for i := 0; i < len(buf); i++ { + if offset+i >= c.encryptStartAt { + buf[i] ^= c.mask + } + } +} diff --git a/decrypt/ximalaya/x2m_crypto.go b/decrypt/ximalaya/x2m_crypto.go new file mode 100644 index 0000000..41d62a4 --- /dev/null +++ b/decrypt/ximalaya/x2m_crypto.go @@ -0,0 +1,34 @@ +package ximalaya + +import ( + _ "embed" + "encoding/binary" +) + +const x2mHeaderSize = 1024 + +var x2mKey = [...]byte{'x', 'm', 'l', 'y'} +var x2mScrambleTable = [x2mHeaderSize]uint16{} + +//go:embed x2m_scramble_table.bin +var x2mScrambleTableBytes []byte + +func init() { + if len(x2mScrambleTableBytes) != 2*x2mHeaderSize { + panic("invalid x2m scramble table") + } + for i := range x2mScrambleTable { + x2mScrambleTable[i] = binary.LittleEndian.Uint16(x2mScrambleTableBytes[i*2:]) + } +} + +// decryptX2MHeader decrypts the header of ximalaya .x2m file. +// make sure input src is 1024(x2mHeaderSize) bytes long. +func decryptX2MHeader(src []byte) []byte { + dst := make([]byte, len(src)) + for dstIdx := range src { + srcIdx := x2mScrambleTable[dstIdx] + dst[dstIdx] = src[srcIdx] ^ x2mKey[dstIdx%len(x2mKey)] + } + return dst +} diff --git a/decrypt/ximalaya/x2m_scramble_table.bin b/decrypt/ximalaya/x2m_scramble_table.bin new file mode 100644 index 0000000..ab586dd Binary files /dev/null and b/decrypt/ximalaya/x2m_scramble_table.bin differ diff --git a/decrypt/ximalaya/x3m_crypto.go b/decrypt/ximalaya/x3m_crypto.go new file mode 100644 index 0000000..b147944 --- /dev/null +++ b/decrypt/ximalaya/x3m_crypto.go @@ -0,0 +1,40 @@ +package ximalaya + +import ( + _ "embed" + "encoding/binary" +) + +var x3mKey = [...]byte{ + '3', '9', '8', '9', 'd', '1', '1', '1', + 'a', 'a', 'd', '5', '6', '1', '3', '9', + '4', '0', 'f', '4', 'f', 'c', '4', '4', + 'b', '6', '3', '9', 'b', '2', '9', '2', +} + +const x3mHeaderSize = 1024 + +var x3mScrambleTable = [x3mHeaderSize]uint16{} + +//go:embed x3m_scramble_table.bin +var x3mScrambleTableBytes []byte + +func init() { + if len(x3mScrambleTableBytes) != 2*x3mHeaderSize { + panic("invalid x3m scramble table") + } + for i := range x3mScrambleTable { + x3mScrambleTable[i] = binary.LittleEndian.Uint16(x3mScrambleTableBytes[i*2:]) + } +} + +// decryptX3MHeader decrypts the header of ximalaya .x3m file. +// make sure input src is 1024 (x3mHeaderSize) bytes long. +func decryptX3MHeader(src []byte) []byte { + dst := make([]byte, len(src)) + for dstIdx := range src { + srcIdx := x3mScrambleTable[dstIdx] + dst[dstIdx] = src[srcIdx] ^ x3mKey[dstIdx%len(x3mKey)] + } + return dst +} diff --git a/decrypt/ximalaya/x3m_scramble_table.bin b/decrypt/ximalaya/x3m_scramble_table.bin new file mode 100644 index 0000000..b43f50e Binary files /dev/null and b/decrypt/ximalaya/x3m_scramble_table.bin differ diff --git a/decrypt/ximalaya/ximalaya.go b/decrypt/ximalaya/ximalaya.go new file mode 100644 index 0000000..07dfd69 --- /dev/null +++ b/decrypt/ximalaya/ximalaya.go @@ -0,0 +1,57 @@ +package ximalaya + +import ( + "bytes" + "fmt" + "io" + + "jsuse.com/dev/dec-music/decrypt" + "jsuse.com/dev/dec-music/internal/sniff" +) + +type decoder struct { + rd io.ReadSeeker + offset int + + audio io.Reader +} + +func newDecoder(p *decrypt.DecoderParams) decrypt.Decoder { + return &decoder{rd: p.Reader} +} + +func (d *decoder) Validate() error { + encryptedHeader := make([]byte, x2mHeaderSize) + if _, err := io.ReadFull(d.rd, encryptedHeader); err != nil { + return fmt.Errorf("ximalaya read header: %w", err) + } + + { // try to decode with x2m + header := decryptX2MHeader(encryptedHeader) + if _, ok := sniff.AudioExtension(header); ok { + d.audio = io.MultiReader(bytes.NewReader(header), d.rd) + return nil + } + } + + { // try to decode with x3m + // not read file again, since x2m and x3m have the same header size + header := decryptX3MHeader(encryptedHeader) + if _, ok := sniff.AudioExtension(header); ok { + d.audio = io.MultiReader(bytes.NewReader(header), d.rd) + return nil + } + } + + return fmt.Errorf("ximalaya: unknown format") +} + +func (d *decoder) Read(p []byte) (n int, err error) { + return d.audio.Read(p) +} + +func init() { + decrypt.RegisterDecoder("x2m", false , newDecoder) + decrypt.RegisterDecoder("x3m", false , newDecoder) + decrypt.RegisterDecoder("xm", false , newDecoder) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b19c42a --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module jsuse.com/dev/dec-music + +go 1.24.0 + +require github.com/urfave/cli/v2 v2.27.7 + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..714b8c1 --- /dev/null +++ b/go.sum @@ -0,0 +1,65 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= +github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI= +github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= +github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI= +github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= +github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= +github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= +github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= +github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= +github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= +github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..7cabe01 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,33 @@ +package crypto + +import "crypto/aes" + +// PKCS7UnPadding removes PKCS#7 padding. +func PKCS7UnPadding(encrypt []byte) []byte { + length := len(encrypt) + if length == 0 { + return encrypt + } + unPadding := int(encrypt[length-1]) + if unPadding <= 0 || unPadding > length { + return encrypt + } + return encrypt[:length-unPadding] +} + +// DecryptAES128ECB decrypts data with AES-128 ECB (block size 16). +func DecryptAES128ECB(data, key []byte) []byte { + cipher, err := aes.NewCipher(key) + if err != nil { + return nil + } + decrypted := make([]byte, len(data)) + size := aes.BlockSize + for bs, be := 0, size; bs < len(data); bs, be = bs+size, be+size { + if be > len(data) { + break + } + cipher.Decrypt(decrypted[bs:be], data[bs:be]) + } + return decrypted +} diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go new file mode 100644 index 0000000..655731b --- /dev/null +++ b/internal/fileutil/fileutil.go @@ -0,0 +1,50 @@ +package fileutil + +import ( + "os" + "path/filepath" + "strings" +) + +var ( + standardMediaExts = map[string]struct{}{ + ".mp3": {}, ".flac": {}, ".wav": {}, ".m4a": {}, + ".ogg": {}, ".opus": {}, ".aac": {}, ".wma": {}, + ".ape": {}, ".alac": {}, ".dsf": {}, ".dff": {}, + } + encryptedMediaExts = map[string]struct{}{ + ".kgma": {}, ".kgm": {}, + ".ncm": {}, + ".qmc": {}, ".qmc0": {}, ".qmc3": {}, ".qmcflac": {}, ".qmcogg": {}, + ".mgg": {}, ".mflac": {}, + ".tm": {}, ".tkm": {}, + ".xiami": {}, ".xm": {}, + ".ximalaya": {}, ".xmly": {}, + ".x2m": {}, ".x3m": {}, + ".kwm": {}, + } + videoMediaExts = map[string]struct{}{ + ".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".flv": {}, + } +) + +// IsMediaFile reports whether the file name looks like a media file this tool handles. +func IsMediaFile(fileName string) bool { + ext := strings.ToLower(filepath.Ext(fileName)) + if _, ok := standardMediaExts[ext]; ok { + return true + } + if _, ok := encryptedMediaExts[ext]; ok { + return true + } + if _, ok := videoMediaExts[ext]; ok { + return true + } + return false +} + +// FileExist reports whether path exists. +func FileExist(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/sniff/audio.go b/internal/sniff/audio.go new file mode 100644 index 0000000..90c453e --- /dev/null +++ b/internal/sniff/audio.go @@ -0,0 +1,105 @@ +package sniff + +import ( + "bytes" + "encoding/binary" + "slices" +) + +type Sniffer interface { + Sniff(header []byte) bool +} + +var audioExtensions = map[string]Sniffer{ + // ref: https://mimesniff.spec.whatwg.org + ".mp3": prefixSniffer("ID3"), // todo: check mp3 without ID3v2 tag + ".ogg": prefixSniffer("OggS"), + ".wav": prefixSniffer("RIFF"), + + // ref: https://www.loc.gov/preservation/digital/formats/fdd/fdd000027.shtml + ".wma": prefixSniffer{ + 0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, + 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c, + }, + + // ref: https://www.garykessler.net/library/file_sigs.html + ".m4a": m4aSniffer{}, // MPEG-4 container, Apple Lossless Audio Codec + ".mp4": &mpeg4Sniffer{}, // MPEG-4 container, other fallback + + ".flac": prefixSniffer("fLaC"), // ref: https://xiph.org/flac/format.html + ".dff": prefixSniffer("FRM8"), // DSDIFF, ref: https://www.sonicstudio.com/pdf/dsd/DSDIFF_1.5_Spec.pdf + +} + +// AudioExtension sniffs the known audio types, and returns the file extension. +// header is recommended to at least 16 bytes. +func AudioExtension(header []byte) (string, bool) { + for ext, sniffer := range audioExtensions { + if sniffer.Sniff(header) { + return ext, true + } + } + return "", false +} + +// AudioExtensionWithFallback is equivalent to AudioExtension, but returns fallback +// most likely to use .mp3 as fallback, because mp3 files may not have ID3v2 tag. +func AudioExtensionWithFallback(header []byte, fallback string) string { + ext, ok := AudioExtension(header) + if !ok { + return fallback + } + return ext +} + +type prefixSniffer []byte + +func (s prefixSniffer) Sniff(header []byte) bool { + return bytes.HasPrefix(header, s) +} + +type m4aSniffer struct{} + +func (m4aSniffer) Sniff(header []byte) bool { + box := readMpeg4FtypBox(header) + if box == nil { + return false + } + + return box.majorBrand == "M4A " || slices.Contains(box.compatibleBrands, "M4A ") +} + +type mpeg4Sniffer struct{} + +func (s *mpeg4Sniffer) Sniff(header []byte) bool { + return readMpeg4FtypBox(header) != nil +} + +type mpeg4FtpyBox struct { + majorBrand string + minorVersion uint32 + compatibleBrands []string +} + +func readMpeg4FtypBox(header []byte) *mpeg4FtpyBox { + if (len(header) < 8) || !bytes.Equal([]byte("ftyp"), header[4:8]) { + return nil // not a valid ftyp box + } + + size := binary.BigEndian.Uint32(header[0:4]) // size + if size < 16 || size%4 != 0 { + return nil // invalid ftyp box + } + + box := mpeg4FtpyBox{ + majorBrand: string(header[8:12]), + minorVersion: binary.BigEndian.Uint32(header[12:16]), + } + + // compatible brands + for i := 16; i < int(size) && i+4 < len(header); i += 4 { + box.compatibleBrands = append(box.compatibleBrands, string(header[i:i+4])) + } + + return &box +} diff --git a/internal/tea/cipher.go b/internal/tea/cipher.go new file mode 100644 index 0000000..c1ff90e --- /dev/null +++ b/internal/tea/cipher.go @@ -0,0 +1,116 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package tea implements the TEA algorithm, as defined in Needham and +// Wheeler's 1994 technical report, “TEA, a Tiny Encryption Algorithm”. See +// http://www.cix.co.uk/~klockstone/tea.pdf for details. +// +// TEA is a legacy cipher and its short block size makes it vulnerable to +// birthday bound attacks (see https://sweet32.info). It should only be used +// where compatibility with legacy systems, not security, is the goal. +// +// Deprecated: any new system should use AES (from crypto/aes, if necessary in +// an AEAD mode like crypto/cipher.NewGCM) or XChaCha20-Poly1305 (from +// golang.org/x/crypto/chacha20poly1305). +package tea + +import ( + "crypto/cipher" + "encoding/binary" + "errors" +) + +const ( + // BlockSize is the size of a TEA block, in bytes. + BlockSize = 8 + + // KeySize is the size of a TEA key, in bytes. + KeySize = 16 + + // delta is the TEA key schedule constant. + delta = 0x9e3779b9 + + // numRounds is the standard number of rounds in TEA. + numRounds = 64 +) + +// tea is an instance of the TEA cipher with a particular key. +type tea struct { + key [16]byte + rounds int +} + +// NewCipher returns an instance of the TEA cipher with the standard number of +// rounds. The key argument must be 16 bytes long. +func NewCipher(key []byte) (cipher.Block, error) { + return NewCipherWithRounds(key, numRounds) +} + +// NewCipherWithRounds returns an instance of the TEA cipher with a given +// number of rounds, which must be even. The key argument must be 16 bytes +// long. +func NewCipherWithRounds(key []byte, rounds int) (cipher.Block, error) { + if len(key) != 16 { + return nil, errors.New("tea: incorrect key size") + } + + if rounds&1 != 0 { + return nil, errors.New("tea: odd number of rounds specified") + } + + c := &tea{ + rounds: rounds, + } + copy(c.key[:], key) + + return c, nil +} + +// BlockSize returns the TEA block size, which is eight bytes. It is necessary +// to satisfy the Block interface in the package "crypto/cipher". +func (*tea) BlockSize() int { + return BlockSize +} + +// Encrypt encrypts the 8 byte buffer src using the key in t and stores the +// result in dst. Note that for amounts of data larger than a block, it is not +// safe to just call Encrypt on successive blocks; instead, use an encryption +// mode like CBC (see crypto/cipher/cbc.go). +func (t *tea) Encrypt(dst, src []byte) { + e := binary.BigEndian + v0, v1 := e.Uint32(src), e.Uint32(src[4:]) + k0, k1, k2, k3 := e.Uint32(t.key[0:]), e.Uint32(t.key[4:]), e.Uint32(t.key[8:]), e.Uint32(t.key[12:]) + + sum := uint32(0) + delta := uint32(delta) + + for i := 0; i < t.rounds/2; i++ { + sum += delta + v0 += ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1) + v1 += ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3) + } + + e.PutUint32(dst, v0) + e.PutUint32(dst[4:], v1) +} + +// Decrypt decrypts the 8 byte buffer src using the key in t and stores the +// result in dst. +func (t *tea) Decrypt(dst, src []byte) { + e := binary.BigEndian + v0, v1 := e.Uint32(src), e.Uint32(src[4:]) + k0, k1, k2, k3 := e.Uint32(t.key[0:]), e.Uint32(t.key[4:]), e.Uint32(t.key[8:]), e.Uint32(t.key[12:]) + + delta := uint32(delta) + sum := delta * uint32(t.rounds/2) // in general, sum = delta * n + + for i := 0; i < t.rounds/2; i++ { + v1 -= ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3) + v0 -= ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1) + sum -= delta + } + + e.PutUint32(dst, v0) + e.PutUint32(dst[4:], v1) +} diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..667dd0b --- /dev/null +++ b/log/log.go @@ -0,0 +1,80 @@ +// Package log provides optional colored console logging, independent of decryption. +package log + +import ( + "fmt" + "os" + "path" + "runtime" + "sync" + "time" +) + +// Logger writes timestamped, colorized log lines to stderr. +type Logger struct{} + +// New returns a logger instance. +func New() *Logger { return &Logger{} } + +func (l *Logger) Debug(a ...any) { output(1, "cyan", a...) } +func (l *Logger) Info(a ...any) { output(1, "green", a...) } +func (l *Logger) Warn(a ...any) { output(1, "yellow", a...) } +func (l *Logger) Error(a ...any) { output(1, "red", a...) } + +// With returns the same logger (scope is ignored; kept for call-site compatibility). +func (l *Logger) With(_ any) *Logger { return l } + +// PrintSuccess prints a green/cyan success line for CLI use. +func PrintSuccess(src, dst string) { + mu.Lock() + defer mu.Unlock() + fmt.Fprintf(os.Stderr, "%s %s: ", time.Now().Format("15:04:05.000"), trace(3)) + fmt.Fprintf(os.Stderr, "操作成功: %s -> %s\n", green(src), cyan(dst)) +} + +var mu sync.Mutex + +func output(skip int, color string, v ...any) { + mu.Lock() + defer mu.Unlock() + fmt.Fprint(os.Stderr, time.Now().Format("15:04:05.000"), " ", trace(skip+2), " ") + fmt.Fprintln(os.Stderr, colorize(color, fmt.Sprint(v...))) +} + +func trace(skip int) string { + _, file, line, ok := runtime.Caller(skip) + if !ok { + return "unknown:0" + } + return fmt.Sprintf("%s:%d", path.Base(file), line) +} + +func colorize(color, s string) string { + if !supportsColor() { + return s + } + const reset = "\033[0m" + switch color { + case "red": + return "\033[31m" + s + reset + case "green": + return "\033[32m" + s + reset + case "yellow": + return "\033[33m" + s + reset + case "cyan": + return "\033[36m" + s + reset + default: + return s + } +} + +func green(s string) string { return colorize("green", s) } +func cyan(s string) string { return colorize("cyan", s) } + +func supportsColor() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + // Windows 10+ and most Unix terminals support ANSI. + return true +} diff --git a/misc/release.sh b/misc/release.sh new file mode 100644 index 0000000..3f73fbc --- /dev/null +++ b/misc/release.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +PLATFORMS=( + "linux/amd64" + "linux/arm64" + "darwin/amd64" + "darwin/arm64" + "windows/amd64" + "windows/386" +) + +DEST_DIR=${DEST_DIR:-"dist"} + +for PLATFORM in "${PLATFORMS[@]}"; do + GOOS=${PLATFORM%/*} + GOARCH=${PLATFORM#*/} + echo "Building for $GOOS/$GOARCH" + + FILENAME="um-$GOOS-$GOARCH" + if [ "$GOOS" = "windows" ]; then + FILENAME="$FILENAME.exe" + fi + + GOOS=$GOOS GOARCH=$GOARCH go build -v \ + -o "${DEST_DIR}/${FILENAME}" \ + -ldflags "-s -w -X main.AppVersion=$(git describe --tags --always --dirty)" \ + ./cmd/um +done + +cd "$DEST_DIR" +sha256sum um-* > sha256sums.txt \ No newline at end of file diff --git a/mmkv/interface.go b/mmkv/interface.go new file mode 100644 index 0000000..8387b32 --- /dev/null +++ b/mmkv/interface.go @@ -0,0 +1,16 @@ +package mmkv + +type Manager interface { + // OpenVault opens a vault with the given id. + // If the vault does not exist, it will be created. + // If id is empty, DefaultVaultID will be used. + OpenVault(id string) (Vault, error) + OpenVaultCrypto(id string, cryptoKey string) (Vault, error) +} + +type Vault interface { + Keys() []string + GetRaw(key string) ([]byte, bool) + GetBytes(key string) ([]byte, error) + GetString(key string) (string, error) +} diff --git a/mmkv/internal/protobuffer.go b/mmkv/internal/protobuffer.go new file mode 100644 index 0000000..8f5c892 --- /dev/null +++ b/mmkv/internal/protobuffer.go @@ -0,0 +1,41 @@ +package internal + +// ProtoBuffer decodes a subset of the protobuf wire format for MMKV payloads. + +type ProtoBuffer struct { + buf []byte + idx int +} + +// NewProtoBuffer returns a buffer whose unread portion is buf. +func NewProtoBuffer(buf []byte) *ProtoBuffer { + return &ProtoBuffer{buf: buf} +} + +// DecodeStringBytes consumes a length-prefixed string. +func (b *ProtoBuffer) DecodeStringBytes() (string, error) { + v, n := ConsumeString(b.buf[b.idx:]) + if n < 0 { + return "", parseError(n) + } + b.idx += n + return v, nil +} + +// DecodeRawBytes consumes a length-prefixed bytes field. +func (b *ProtoBuffer) DecodeRawBytes(alloc bool) ([]byte, error) { + v, n := ConsumeBytes(b.buf[b.idx:]) + if n < 0 { + return nil, parseError(n) + } + b.idx += n + if alloc { + v = append([]byte(nil), v...) + } + return v, nil +} + +// Unread returns the unread portion of the buffer. +func (b *ProtoBuffer) Unread() []byte { + return b.buf[b.idx:] +} diff --git a/mmkv/internal/wire.go b/mmkv/internal/wire.go new file mode 100644 index 0000000..eedb575 --- /dev/null +++ b/mmkv/internal/wire.go @@ -0,0 +1,65 @@ +package internal + +// Minimal protobuf wire helpers (bytes/string fields only). + +const ( + errCodeOverflow = -2 + errCodeTruncated = -1 +) + +func consumeVarint(b []byte) (uint64, int) { + var x uint64 + var s uint + for i, c := range b { + if c < 0x80 { + if i > 9 || i == 9 && c > 1 { + return 0, errCodeOverflow + } + return x | uint64(c)< uint64(len(b)-n) { + return nil, errCodeOverflow + } + return b[n : n+int(v)], n + int(v) +} + +// ConsumeString parses a length-delimited string field. +func ConsumeString(b []byte) (string, int) { + v, n := ConsumeBytes(b) + if n < 0 { + return "", n + } + return string(v), n +} + +func parseError(code int) error { + switch code { + case errCodeOverflow: + return errOverflow + case errCodeTruncated: + return errTruncated + default: + return errTruncated + } +} + +var ( + errOverflow = &parseErr{"overflow"} + errTruncated = &parseErr{"truncated"} +) + +type parseErr struct{ msg string } + +func (e *parseErr) Error() string { return "protowire: " + e.msg } diff --git a/mmkv/manager.go b/mmkv/manager.go new file mode 100644 index 0000000..0e30382 --- /dev/null +++ b/mmkv/manager.go @@ -0,0 +1,96 @@ +package mmkv + +import ( + "fmt" + "os" + "path" +) + +const ( + DefaultVaultID = "mmkv.default" +) + +type manager struct { + dir string + vaults map[string]Vault +} + +// NewManager creates a new MMKV Manager. +func NewManager(dir string) (Manager, error) { + // check dir exists + info, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("failed to stat dir: %w", err) + } + + if !info.IsDir() { + return nil, fmt.Errorf("not a directory") + } + + return &manager{ + dir: dir, + vaults: make(map[string]Vault), + }, nil +} + +func (m *manager) OpenVault(id string) (Vault, error) { + if id == "" { + id = DefaultVaultID + } + + if v, ok := m.vaults[id]; ok { + return v, nil + } + + vault, err := m.openVault(id, "") + if err != nil { + return nil, fmt.Errorf("failed to open vault: %w", err) + } + m.vaults[id] = vault + + return vault, nil +} + +func (m *manager) OpenVaultCrypto(id string, cryptoKey string) (Vault, error) { + if id == "" { + id = DefaultVaultID + } + + if v, ok := m.vaults[id]; ok { + return v, nil + } + + vault, err := m.openVault(id, cryptoKey) + if err != nil { + return nil, fmt.Errorf("failed to open vault: %w", err) + } + m.vaults[id] = vault + + return vault, nil +} + +func (m *manager) openVault(id string, cryptoKey string) (Vault, error) { + metaFile, err := os.Open(path.Join(m.dir, id+".crc")) + if err != nil { + return nil, fmt.Errorf("failed to open metadata file: %w", err) + } + defer metaFile.Close() + + vaultFile, err := os.Open(path.Join(m.dir, id)) + if err != nil { + return nil, fmt.Errorf("failed to open vault file: %w", err) + } + defer vaultFile.Close() + + meta, err := loadMetadata(metaFile) + if err != nil { + return nil, fmt.Errorf("failed to load metadata: %w", err) + } + + v, err := loadVault(vaultFile, meta, cryptoKey) + if err != nil { + return nil, fmt.Errorf("failed to load vault: %w", err) + } + + return v, nil +} diff --git a/mmkv/manager_test.go b/mmkv/manager_test.go new file mode 100644 index 0000000..b01c368 --- /dev/null +++ b/mmkv/manager_test.go @@ -0,0 +1,39 @@ +package mmkv + +import "testing" + +func TestNewManager(t *testing.T) { + t.Run("Default", func(t *testing.T) { + mgr, err := NewManager("./testdata") + if err != nil { + t.Fatal(err) + } + vault, err := mgr.OpenVault("") + if err != nil { + t.Fatal(err) + } + if vault == nil { + t.Fatal("vault is nil") + } + }) + t.Run("Crypto", func(t *testing.T) { + mgr, err := NewManager("./testdata") + if err != nil { + t.Fatal(err) + } + vault, err := mgr.OpenVaultCrypto("crypto", "123456") + if err != nil { + t.Fatal(err) + } + val, err := vault.GetString("world") + if err != nil { + t.Fatal(err) + } + if val != "hello" { + t.Fatalf("world = %q, want hello", val) + } + if _, err = vault.GetBytes("foo"); err == nil { + t.Fatal("expected error for missing key foo") + } + }) +} diff --git a/mmkv/metadata.go b/mmkv/metadata.go new file mode 100644 index 0000000..4670278 --- /dev/null +++ b/mmkv/metadata.go @@ -0,0 +1,56 @@ +package mmkv + +import ( + "encoding/binary" + "fmt" + "io" +) + +type metadata struct { + // added in version 0 + crc32 uint32 + + // added in version 1 + version uint32 + sequence uint32 // full write back count + + // added in version 2 + aesVector []byte // random iv for encryption, aes.BlockSize (16 bytes) + + // added in version 3, try to reduce file corruption + actualSize uint32 + lastActualSize uint32 + lastCRC32 uint32 + + //_reversed []byte // 64 bytes +} + +func loadMetadata(rd io.Reader) (*metadata, error) { + buf := make([]byte, 0x68) + _, err := io.ReadFull(rd, buf) + if err != nil { + return nil, fmt.Errorf("failed to read metadata: %w", err) + } + + m := &metadata{} + + m.crc32 = binary.LittleEndian.Uint32(buf[0:4]) + m.version = binary.LittleEndian.Uint32(buf[4:8]) + + if m.version >= 1 { + m.sequence = binary.LittleEndian.Uint32(buf[8:12]) + } + + if m.version >= 2 { + m.aesVector = buf[12:28] + } + + if m.version >= 3 { + m.actualSize = binary.LittleEndian.Uint32(buf[28:32]) + m.lastActualSize = binary.LittleEndian.Uint32(buf[32:36]) + m.lastCRC32 = binary.LittleEndian.Uint32(buf[36:40]) + } + + //m._reversed = buf[40:104] + return m, nil +} diff --git a/mmkv/metadata_test.go b/mmkv/metadata_test.go new file mode 100644 index 0000000..211f1f5 --- /dev/null +++ b/mmkv/metadata_test.go @@ -0,0 +1,42 @@ +package mmkv + +import ( + "bytes" + "os" + "testing" +) + +func Test_loadMetadata(t *testing.T) { + file, err := os.Open("./testdata/mmkv.default.crc") + if err != nil { + t.Fatal(err) + } + + meta, err := loadMetadata(file) + if err != nil { + t.Fatal(err) + } + + if meta.version != 3 { + t.Fatalf("version = %d, want 3", meta.version) + } + if meta.sequence != 1 { + t.Fatalf("sequence = %d, want 1", meta.sequence) + } + if meta.actualSize != 28 { + t.Fatalf("actualSize = %d, want 28", meta.actualSize) + } + if meta.crc32 != 197326043 { + t.Fatalf("crc32 = %d, want 197326043", meta.crc32) + } + if meta.lastActualSize != 4 { + t.Fatalf("lastActualSize = %d, want 4", meta.lastActualSize) + } + if meta.lastCRC32 != 1285129681 { + t.Fatalf("lastCRC32 = %d, want 1285129681", meta.lastCRC32) + } + wantIV := bytes.Repeat([]byte{0x00}, 16) + if !bytes.Equal(meta.aesVector, wantIV) { + t.Fatalf("aesVector = %v, want zeros", meta.aesVector) + } +} diff --git a/mmkv/vault.go b/mmkv/vault.go new file mode 100644 index 0000000..2915da5 --- /dev/null +++ b/mmkv/vault.go @@ -0,0 +1,105 @@ +package mmkv + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/binary" + "fmt" + "hash/crc32" + "io" + + "jsuse.com/dev/dec-music/mmkv/internal" +) + +type vault map[string][]byte + +func (v vault) keys() []string { + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + return keys +} + +func (v vault) Keys() []string { + return v.keys() +} + +func (v vault) GetRaw(key string) ([]byte, bool) { + val, ok := v[key] + return val, ok +} + +func (v vault) GetBytes(key string) ([]byte, error) { + raw, ok := v[key] + if !ok { + return nil, fmt.Errorf("key not found: %s", key) + } + + val, n := internal.ConsumeBytes(raw) + if n < 0 { + return nil, fmt.Errorf("invalid protobuf bytes") + } + + return val, nil +} + +func (v vault) GetString(key string) (string, error) { + val, err := v.GetBytes(key) + if err != nil { + return "", err + } + return string(val), nil +} + +func loadVault(src io.Reader, m *metadata, cryptoKey string) (Vault, error) { + fileSizeBuf := make([]byte, 4) + _, err := io.ReadFull(src, fileSizeBuf) + if err != nil { + return nil, fmt.Errorf("failed to read file size: %w", err) + } + size := binary.LittleEndian.Uint32(fileSizeBuf) + + if m != nil && size != m.actualSize { + return nil, fmt.Errorf("metadata and vault payload size mismatch") + } + + buf := make([]byte, size) + _, err = io.ReadFull(src, buf) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + if m != nil && m.crc32 != crc32.ChecksumIEEE(buf) { + return nil, fmt.Errorf("metadata and vault payload crc32 mismatch") + } + + if len(cryptoKey) > 0 { + mKey := make([]byte, aes.BlockSize) + copy(mKey, cryptoKey) + + block, err := aes.NewCipher(mKey) + if err != nil { + return nil, fmt.Errorf("failed to create aes cipher") + } + stream := cipher.NewCFBDecrypter(block, m.aesVector) + stream.XORKeyStream(buf, buf) + } + + v := make(vault) + rd := internal.NewProtoBuffer(buf[4:]) + + for len(rd.Unread()) > 0 { + key, err := rd.DecodeStringBytes() + if err != nil { + return nil, fmt.Errorf("failed to decode key: %w", err) + } + val, err := rd.DecodeRawBytes(false) + if err != nil { + return nil, fmt.Errorf("failed to decode value: %w", err) + } + v[key] = val + } + + return v, nil +} diff --git a/mmkv/vault_test.go b/mmkv/vault_test.go new file mode 100644 index 0000000..342a6c4 --- /dev/null +++ b/mmkv/vault_test.go @@ -0,0 +1,34 @@ +package mmkv + +import ( + "os" + "testing" +) + +func Test_loadVault(t *testing.T) { + file, err := os.Open("./testdata/mmkv.default") + if err != nil { + t.Fatal(err) + } + + v, err := loadVault(file, nil, "") + if err != nil { + t.Fatal(err) + } + + if len(v.Keys()) != 2 { + t.Fatalf("keys len = %d, want 2", len(v.Keys())) + } + + val, err := v.GetString("world") + if err != nil { + t.Fatal(err) + } + if val != "hello" { + t.Fatalf("world = %q, want hello", val) + } + + if _, err = v.GetBytes("foo"); err == nil { + t.Fatal("expected error for missing key foo") + } +}