From 8e8afb0f6637e2e0776bd095b466f340edbf8e23 Mon Sep 17 00:00:00 2001 From: Markury Date: Thu, 2 Oct 2025 20:26:14 -0400 Subject: [PATCH] fix: use rust blowfish instead of node The JavaScript blowfish-node library had a critical bug where it would sometimes return 2047 bytes instead of 2048 during decryption, causing byte alignment issues that corrupted FLAC audio at specific intervals (~every 32/82 seconds). Changes: - Add Rust dependencies: blowfish, md5, byteorder - Implement new module in Rust with proper Blowfish CBC - Add decryption Tauri command - Update frontend to call Rust decryption instead of JavaScript - Remove buggy JavaScript blowfish implementation - Update decryption algorithm (6144-byte windows) --- bun.lock | 3 - package.json | 1 - src-tauri/Cargo.lock | 38 +++++++++ src-tauri/Cargo.toml | 3 + src-tauri/src/deezer_crypto.rs | 112 ++++++++++++++++++++++++++ src-tauri/src/lib.rs | 14 +++- src/lib/services/deezer/crypto.ts | 50 +----------- src/lib/services/deezer/downloader.ts | 77 ++---------------- 8 files changed, 176 insertions(+), 122 deletions(-) create mode 100644 src-tauri/src/deezer_crypto.rs diff --git a/bun.lock b/bun.lock index 066fb1a..65c5387 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,6 @@ "@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-sql": "^2.3.0", "@tauri-apps/plugin-store": "~2", - "blowfish-node": "^1.1.4", "music-metadata": "^11.9.0", "uuid": "^13.0.0", }, @@ -208,8 +207,6 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - "blowfish-node": ["blowfish-node@1.1.4", "", {}, "sha512-Iahpxc/cutT0M0tgwV5goklB+EzDuiYLgwJg050AmUG2jSIOpViWMLdnRgBxzZuNfswAgHSUiIdvmNdgL2v6DA=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], diff --git a/package.json b/package.json index 193c5ba..b40f9b1 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-sql": "^2.3.0", "@tauri-apps/plugin-store": "~2", - "blowfish-node": "^1.1.4", "music-metadata": "^11.9.0", "uuid": "^13.0.0" }, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1f71efc..17d0105 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6,7 +6,10 @@ version = 4 name = "Shark" version = "0.1.0" dependencies = [ + "blowfish", + "byteorder", "id3", + "md5", "metaflac", "serde", "serde_json", @@ -365,6 +368,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "brotli" version = "8.0.2" @@ -541,6 +554,16 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -2037,6 +2060,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -2353,6 +2385,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.7.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 49f8907..6e2d857 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,4 +30,7 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] } id3 = "1.16.3" metaflac = "0.2.8" tauri-plugin-process = "2" +blowfish = "0.9" +md5 = "0.7" +byteorder = "1.5.0" diff --git a/src-tauri/src/deezer_crypto.rs b/src-tauri/src/deezer_crypto.rs new file mode 100644 index 0000000..8663c39 --- /dev/null +++ b/src-tauri/src/deezer_crypto.rs @@ -0,0 +1,112 @@ +use blowfish::cipher::{BlockDecrypt, KeyInit}; +use blowfish::Blowfish; +use byteorder::BigEndian; + +/// Generate Blowfish key for Deezer track decryption +pub fn generate_blowfish_key(track_id: &str) -> Vec { + const SECRET: &[u8] = b"g4el58wc0zvf9na1"; + + // MD5 hash of track ID + let id_md5 = format!("{:x}", md5::compute(track_id.as_bytes())); + + // Generate 16-byte key by XORing MD5 parts with secret + let mut bf_key = Vec::with_capacity(16); + for i in 0..16 { + let md5_byte1 = id_md5.as_bytes()[i]; + let md5_byte2 = id_md5.as_bytes()[i + 16]; + let secret_byte = SECRET[i]; + bf_key.push(md5_byte1 ^ md5_byte2 ^ secret_byte); + } + + bf_key +} + +/// Decrypt a single 2048-byte chunk using Blowfish CBC +pub fn decrypt_chunk(chunk: &[u8], blowfish_key: &[u8]) -> Vec { + let cipher = Blowfish::::new_from_slice(blowfish_key) + .expect("Invalid key length"); + + let mut result = chunk.to_vec(); + let iv = [0u8, 1, 2, 3, 4, 5, 6, 7]; + + // Decrypt in CBC mode + let mut prev_block = iv; + + for chunk_idx in (0..result.len()).step_by(8) { + if chunk_idx + 8 <= result.len() { + let mut block = [0u8; 8]; + block.copy_from_slice(&result[chunk_idx..chunk_idx + 8]); + + let encrypted_block = block; + cipher.decrypt_block((&mut block).into()); + + // XOR with previous ciphertext (CBC mode) + for i in 0..8 { + block[i] ^= prev_block[i]; + } + + result[chunk_idx..chunk_idx + 8].copy_from_slice(&block); + prev_block = encrypted_block; + } + } + + result +} + +/// Decrypt track data using Deezer's encryption scheme +/// Every 3 chunks (6144 bytes), only the first 2048 bytes are encrypted +pub fn decrypt_track(data: &[u8], track_id: &str) -> Vec { + const CHUNK_SIZE: usize = 2048; + const WINDOW_SIZE: usize = CHUNK_SIZE * 3; // 6144 + + let blowfish_key = generate_blowfish_key(track_id); + let mut result = Vec::with_capacity(data.len()); + + let mut offset = 0; + + while offset < data.len() { + let remaining = data.len() - offset; + + if remaining >= WINDOW_SIZE { + // Full window: decrypt first 2048 bytes, keep next 4096 as-is + let encrypted_chunk = &data[offset..offset + CHUNK_SIZE]; + let plain_part = &data[offset + CHUNK_SIZE..offset + WINDOW_SIZE]; + + let decrypted = decrypt_chunk(encrypted_chunk, &blowfish_key); + result.extend_from_slice(&decrypted); + result.extend_from_slice(plain_part); + + offset += WINDOW_SIZE; + } else if remaining >= CHUNK_SIZE { + // Partial window: decrypt first 2048 bytes, keep rest as-is + let encrypted_chunk = &data[offset..offset + CHUNK_SIZE]; + let plain_part = &data[offset + CHUNK_SIZE..]; + + let decrypted = decrypt_chunk(encrypted_chunk, &blowfish_key); + result.extend_from_slice(&decrypted); + result.extend_from_slice(plain_part); + + offset = data.len(); + } else { + // Less than 2048 bytes: keep as-is + result.extend_from_slice(&data[offset..]); + offset = data.len(); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_key_generation() { + let key = generate_blowfish_key("99756342"); + assert_eq!(key.len(), 16); + // Key should be: 3333676c346d7c62372b7c3e6d32626c (in hex) + assert_eq!(format!("{:02x}", key[0]), "33"); + assert_eq!(format!("{:02x}", key[1]), "33"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 630b6c1..e2c4df6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ use tauri_plugin_sql::{Migration, MigrationKind}; mod tagger; mod metadata; +mod deezer_crypto; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] @@ -26,6 +27,12 @@ fn read_audio_metadata(path: String) -> Result metadata::read_audio_metadata(&path) } +/// Decrypt Deezer track data +#[tauri::command] +fn decrypt_deezer_track(data: Vec, track_id: String) -> Result, String> { + Ok(deezer_crypto::decrypt_track(&data, &track_id)) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let library_migrations = vec![Migration { @@ -143,7 +150,12 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) - .invoke_handler(tauri::generate_handler![greet, tag_audio_file, read_audio_metadata]) + .invoke_handler(tauri::generate_handler![ + greet, + tag_audio_file, + read_audio_metadata, + decrypt_deezer_track + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/lib/services/deezer/crypto.ts b/src/lib/services/deezer/crypto.ts index a58e126..5a0be6e 100644 --- a/src/lib/services/deezer/crypto.ts +++ b/src/lib/services/deezer/crypto.ts @@ -5,7 +5,6 @@ import { ecb } from '@noble/ciphers/aes.js'; import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils.js'; -import Blowfish from 'blowfish-node'; /** * MD5 hash implementation @@ -184,53 +183,8 @@ export function ecbDecrypt(key: string, data: string): string { return new TextDecoder().decode(decrypted); } -/** - * Generate Blowfish key for track decryption - */ -export function generateBlowfishKey(trackId: string): Uint8Array { - const SECRET = 'g4el58wc0zvf9na1'; - const idMd5 = md5(trackId); - const bfKey = new Uint8Array(16); - - for (let i = 0; i < 16; i++) { - bfKey[i] = idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ SECRET.charCodeAt(i); - } - - return bfKey; -} - -/** - * Decrypt a chunk using Blowfish CBC - */ -export function decryptChunk(chunk: Uint8Array, blowfishKey: Uint8Array): Uint8Array { - try { - // Convert Uint8Array to the format blowfish-node expects - const bf = new Blowfish(blowfishKey, Blowfish.MODE.CBC, Blowfish.PADDING.NULL); - - // Set IV as Uint8Array - const iv = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]); - bf.setIv(iv); - - // Decode and return as Uint8Array - const decrypted = bf.decode(chunk, Blowfish.TYPE.UINT8_ARRAY); - - // Verify decryption worked - if (!decrypted || decrypted.length === 0) { - throw new Error('Decryption returned empty result'); - } - - return new Uint8Array(decrypted); - } catch (error) { - // Only log the first few errors to avoid spam - if (Math.random() < 0.01) { // Log ~1% of errors - console.error('Error decrypting chunk (sample):', error, { - chunkLength: chunk.length, - keyLength: blowfishKey.length - }); - } - return chunk; // Return original if decryption fails - } -} +// Note: Blowfish decryption is now handled by Rust (see src-tauri/src/deezer_crypto.rs) +// The decrypt_deezer_track Tauri command should be used instead /** * Generate stream path for download URL diff --git a/src/lib/services/deezer/downloader.ts b/src/lib/services/deezer/downloader.ts index 80aa5dd..46b6ee7 100644 --- a/src/lib/services/deezer/downloader.ts +++ b/src/lib/services/deezer/downloader.ts @@ -4,7 +4,7 @@ import { fetch } from '@tauri-apps/plugin-http'; import { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs'; -import { generateBlowfishKey, decryptChunk } from './crypto'; +import { invoke } from '@tauri-apps/api/core'; import { generateTrackPath } from './paths'; import { tagAudioFile } from './tagger'; import { downloadCover, saveCoverToAlbumFolder } from './imageDownload'; @@ -86,8 +86,13 @@ export async function downloadTrack( let decryptedData: Uint8Array; if (isCrypted) { - console.log('Decrypting track...'); - decryptedData = await decryptTrackData(encryptedData, track.id.toString()); + console.log('Decrypting track using Rust...'); + // Call Rust decryption function + const decrypted = await invoke('decrypt_deezer_track', { + data: Array.from(encryptedData), + trackId: track.id.toString() + }); + decryptedData = new Uint8Array(decrypted); } else { decryptedData = encryptedData; } @@ -182,72 +187,6 @@ export async function downloadTrack( } } -/** - * Decrypt track data using Blowfish CBC - * Deezer encrypts every 3rd chunk of 2048 bytes - */ -async function decryptTrackData(data: Uint8Array, trackId: string): Promise { - const chunkSize = 2048; - const blowfishKey = generateBlowfishKey(trackId); - const result: Uint8Array[] = []; - - let offset = 0; - let chunkIndex = 0; - - // Skip initial padding (null bytes before actual data) - while (offset < data.length && data[offset] === 0) { - offset++; - } - - // If we found padding, check if next bytes are 'ftyp' (MP4) or 'ID3' (MP3) or 'fLaC' (FLAC) - if (offset > 0 && offset + 8 < data.length) { - const header = String.fromCharCode(...data.slice(offset + 4, offset + 8)); - if (header === 'ftyp') { - // Skip the null padding - result.push(data.slice(0, offset)); - } else { - // Reset if we didn't find expected header - offset = 0; - } - } else { - offset = 0; - } - - while (offset < data.length) { - const remainingBytes = data.length - offset; - const currentChunkSize = Math.min(chunkSize, remainingBytes); - const chunk = data.slice(offset, offset + currentChunkSize); - - // Decrypt every 3rd chunk (0, 3, 6, 9, ...) - if (chunkIndex % 3 === 0 && chunk.length === chunkSize) { - try { - const decrypted = decryptChunk(chunk, blowfishKey); - result.push(decrypted); - } catch (error) { - console.error('Error decrypting chunk:', error); - result.push(chunk); // Use original if decryption fails - } - } else { - result.push(chunk); - } - - offset += currentChunkSize; - chunkIndex++; - } - - // Combine all chunks - const totalLength = result.reduce((sum, chunk) => sum + chunk.length, 0); - const combined = new Uint8Array(totalLength); - let position = 0; - - for (const chunk of result) { - combined.set(chunk, position); - position += chunk.length; - } - - return combined; -} - /** * Check if a track file already exists */