mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
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:
3
bun.lock
3
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=="],
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
38
src-tauri/Cargo.lock
generated
38
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
112
src-tauri/src/deezer_crypto.rs
Normal file
112
src-tauri/src/deezer_crypto.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<number[]>('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<Uint8Array> {
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user