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

View File

@@ -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
*/