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)
This commit is contained in:
2025-10-02 20:26:14 -04:00
parent e1e7817c71
commit 8e8afb0f66
8 changed files with 176 additions and 122 deletions

38
src-tauri/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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<u8> {
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<u8> {
let cipher = Blowfish::<BigEndian>::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<u8> {
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");
}
}

View File

@@ -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::AudioMetadata, String>
metadata::read_audio_metadata(&path)
}
/// Decrypt Deezer track data
#[tauri::command]
fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>, 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");
}