From c12ee8bfce806a61caf9f2fded8eb098d895631c Mon Sep 17 00:00:00 2001 From: Sarv Date: Wed, 2 Apr 2025 14:59:48 +0800 Subject: [PATCH] update dat2img (#11) --- internal/chatlog/manager.go | 6 + pkg/util/dat2img/dat2img.go | 252 +++++++++++++++++++++++++++++++++++- 2 files changed, 253 insertions(+), 5 deletions(-) diff --git a/internal/chatlog/manager.go b/internal/chatlog/manager.go index 168c7fa..838f72f 100644 --- a/internal/chatlog/manager.go +++ b/internal/chatlog/manager.go @@ -13,6 +13,7 @@ import ( "github.com/sjzar/chatlog/internal/chatlog/mcp" "github.com/sjzar/chatlog/internal/chatlog/wechat" "github.com/sjzar/chatlog/pkg/util" + "github.com/sjzar/chatlog/pkg/util/dat2img" ) // Manager 管理聊天日志应用 @@ -96,6 +97,11 @@ func (m *Manager) StartService() error { return err } + // 如果是 4.0 版本,更新下 xorkey + if m.ctx.Version == 4 { + go dat2img.ScanAndSetXorKey(m.ctx.DataDir) + } + // 更新状态 m.ctx.SetHTTPEnabled(true) diff --git a/pkg/util/dat2img/dat2img.go b/pkg/util/dat2img/dat2img.go index daf8b29..71f0e4c 100644 --- a/pkg/util/dat2img/dat2img.go +++ b/pkg/util/dat2img/dat2img.go @@ -1,31 +1,53 @@ package dat2img -// copy from: https://github.com/tujiaw/wechat_dat_to_image +// Implementation based on: +// - https://github.com/tujiaw/wechat_dat_to_image +// - https://github.com/LC044/WeChatMsg/blob/6535ed0/wxManager/decrypt/decrypt_dat.py import ( + "bytes" + "crypto/aes" + "encoding/binary" "fmt" + "os" + "path/filepath" + "strings" ) +// Format defines the header and extension for different image types type Format struct { Header []byte Ext string } var ( + // Common image format definitions JPG = Format{Header: []byte{0xFF, 0xD8, 0xFF}, Ext: "jpg"} PNG = Format{Header: []byte{0x89, 0x50, 0x4E, 0x47}, Ext: "png"} GIF = Format{Header: []byte{0x47, 0x49, 0x46, 0x38}, Ext: "gif"} TIFF = Format{Header: []byte{0x49, 0x49, 0x2A, 0x00}, Ext: "tiff"} BMP = Format{Header: []byte{0x42, 0x4D}, Ext: "bmp"} Formats = []Format{JPG, PNG, GIF, TIFF, BMP} + + // WeChat v4 related constants + V4XorKey byte = 0x37 // Default XOR key for WeChat v4 dat files + V4DatHeader = []byte{0x07, 0x08, 0x56, 0x31} // WeChat v4 dat file header + JpgTail = []byte{0xFF, 0xD9} // JPG file tail marker ) +// Dat2Image converts WeChat dat file data to image data +// Returns the decoded image data, file extension, and any error encountered func Dat2Image(data []byte) ([]byte, string, error) { - if len(data) < 4 { return nil, "", fmt.Errorf("data length is too short: %d", len(data)) } + // Check if this is a WeChat v4 dat file + if len(data) >= 6 && bytes.Equal(data[:4], V4DatHeader) { + return Dat2ImageV4(data) + } + + // For older WeChat versions, use XOR decryption findFormat := func(data []byte, header []byte) bool { xorBit := data[0] ^ header[0] for i := 0; i < len(header); i++ { @@ -37,20 +59,21 @@ func Dat2Image(data []byte) ([]byte, string, error) { } var xorBit byte - var find bool + var found bool var ext string for _, format := range Formats { - if find = findFormat(data, format.Header); find { + if found = findFormat(data, format.Header); found { xorBit = data[0] ^ format.Header[0] ext = format.Ext break } } - if !find { + if !found { return nil, "", fmt.Errorf("unknown image type: %x %x", data[0], data[1]) } + // Apply XOR decryption out := make([]byte, len(data)) for i := range data { out[i] = data[i] ^ xorBit @@ -58,3 +81,222 @@ func Dat2Image(data []byte) ([]byte, string, error) { return out, ext, nil } + +// calculateXorKeyV4 calculates the XOR key for WeChat v4 dat files +// by analyzing the file tail against known JPG ending bytes (FF D9) +func calculateXorKeyV4(data []byte) (byte, error) { + if len(data) < 2 { + return 0, fmt.Errorf("data too short to calculate XOR key") + } + + // Get the last two bytes of the file + fileTail := data[len(data)-2:] + + // Assuming it's a JPG file, the tail should be FF D9 + xorKeys := make([]byte, 2) + for i := 0; i < 2; i++ { + xorKeys[i] = fileTail[i] ^ JpgTail[i] + } + + // Verify that both bytes yield the same XOR key + if xorKeys[0] == xorKeys[1] { + return xorKeys[0], nil + } + + // If inconsistent, return the first byte as key with a warning + return xorKeys[0], fmt.Errorf("inconsistent XOR key, using first byte: 0x%x", xorKeys[0]) +} + +// ScanAndSetXorKey scans a directory for "_t.dat" files to calculate and set +// the global XOR key for WeChat v4 dat files +// Returns the found key and any error encountered +func ScanAndSetXorKey(dirPath string) (byte, error) { + // Walk the directory recursively + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Only process "_t.dat" files (thumbnail files) + if !strings.HasSuffix(info.Name(), "_t.dat") { + return nil + } + + // Read file content + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + // Check if it's a WeChat v4 dat file + if len(data) < 6 || !bytes.Equal(data[:4], V4DatHeader) { + return nil + } + + // Parse file header + if len(data) < 15 { + return nil + } + + // Get XOR encryption length + xorEncryptLen := binary.LittleEndian.Uint32(data[10:14]) + + // Get data after header + fileData := data[15:] + + // Skip if there's no XOR-encrypted part + if xorEncryptLen == 0 || uint32(len(fileData)) <= uint32(len(fileData))-xorEncryptLen { + return nil + } + + // Get XOR-encrypted part + xorData := fileData[uint32(len(fileData))-xorEncryptLen:] + + // Calculate XOR key + key, err := calculateXorKeyV4(xorData) + if err != nil { + return nil + } + + // Set global XOR key + V4XorKey = key + + // Stop traversal after finding a valid key + return filepath.SkipAll + }) + + if err != nil && err != filepath.SkipAll { + return V4XorKey, fmt.Errorf("error scanning directory: %v", err) + } + + return V4XorKey, nil +} + +// Dat2ImageV4 processes WeChat v4 dat image files +// WeChat v4 uses a combination of AES-ECB and XOR encryption +func Dat2ImageV4(data []byte) ([]byte, string, error) { + if len(data) < 15 { + return nil, "", fmt.Errorf("data length is too short for WeChat v4 format: %d", len(data)) + } + + // Parse dat file header: + // - 6 bytes: 0x07085631 (dat file identifier) + // - 4 bytes: int (little-endian) AES-ECB128 encryption length + // - 4 bytes: int (little-endian) XOR encryption length + // - 1 byte: 0x01 (unknown) + + // Read AES encryption length + aesEncryptLen := binary.LittleEndian.Uint32(data[6:10]) + // Read XOR encryption length + xorEncryptLen := binary.LittleEndian.Uint32(data[10:14]) + + // Data after header + fileData := data[15:] + + // AES encrypted part (max 1KB) + // Round up to multiple of 16 bytes for AES block size + aesEncryptLen0 := (aesEncryptLen)/16*16 + 16 + if aesEncryptLen0 > uint32(len(fileData)) { + aesEncryptLen0 = uint32(len(fileData)) + } + + // Decrypt AES part + aesDecryptedData, err := decryptAESECB(fileData[:aesEncryptLen0], []byte("cfcd208495d565ef")) + if err != nil { + return nil, "", fmt.Errorf("AES decrypt error: %v", err) + } + + // Prepare result buffer + var result []byte + + // Add decrypted AES part (remove padding if necessary) + if len(aesDecryptedData) > int(aesEncryptLen) { + result = append(result, aesDecryptedData[:aesEncryptLen]...) + } else { + result = append(result, aesDecryptedData...) + } + + // Add unencrypted middle part + middleStart := aesEncryptLen0 + middleEnd := uint32(len(fileData)) - xorEncryptLen + if middleStart < middleEnd { + result = append(result, fileData[middleStart:middleEnd]...) + } + + // Process XOR-encrypted part (file tail) + if xorEncryptLen > 0 && middleEnd < uint32(len(fileData)) { + xorData := fileData[middleEnd:] + + // Apply XOR decryption using global key + xorDecrypted := make([]byte, len(xorData)) + for i := range xorData { + xorDecrypted[i] = xorData[i] ^ V4XorKey + } + + result = append(result, xorDecrypted...) + } + + // Identify image type from decrypted data + imgType := "" + for _, format := range Formats { + if len(result) >= len(format.Header) && bytes.Equal(result[:len(format.Header)], format.Header) { + imgType = format.Ext + break + } + } + + if imgType == "" { + return nil, "", fmt.Errorf("unknown image type after decryption") + } + + return result, imgType, nil +} + +// decryptAESECB decrypts data using AES in ECB mode +func decryptAESECB(data, key []byte) ([]byte, error) { + if len(data) == 0 { + return nil, nil + } + + // Create AES cipher + cipher, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // Ensure data length is a multiple of block size + if len(data)%aes.BlockSize != 0 { + return nil, fmt.Errorf("data length is not a multiple of block size") + } + + decrypted := make([]byte, len(data)) + + // ECB mode requires block-by-block decryption + for bs, be := 0, aes.BlockSize; bs < len(data); bs, be = bs+aes.BlockSize, be+aes.BlockSize { + cipher.Decrypt(decrypted[bs:be], data[bs:be]) + } + + // Handle PKCS#7 padding + padding := int(decrypted[len(decrypted)-1]) + if padding > 0 && padding <= aes.BlockSize { + // Validate padding + valid := true + for i := len(decrypted) - padding; i < len(decrypted); i++ { + if decrypted[i] != byte(padding) { + valid = false + break + } + } + + if valid { + return decrypted[:len(decrypted)-padding], nil + } + } + + return decrypted, nil +}