gerdur-core 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.2 - 2026-08-30
4
+
5
+ ### Fixed
6
+
7
+ - **`decryptDownload` now works on Node >= 17 / OpenSSL 3.** It used `crypto('bf-cbc')`, which OpenSSL 3 disables by default (`ERR_OSSL_EVP_UNSUPPORTED`). Replaced with a correct, dependency-free Blowfish (~290 MiB/s). Verified against the canonical Blowfish test vectors and a real-track fixture (`__tests__/decrypt.ts`).
8
+
9
+ ### Added
10
+
11
+ - `TrackDecryptStream` — decrypt a track from ordered socket chunks (`write()` / `final()`); constant memory, CPU work hides behind the network read.
12
+ - `ExpiredTrackToken` error — thrown by `getTrackDownloadUrl` when `TRACK_TOKEN_EXPIRE` has passed or the media API returns code 2000/2001. Re-fetch the track with `getTrackInfo(SNG_ID)` and retry.
13
+
14
+ ### Changed
15
+
16
+ - Internal LRU (`fast-lru`) rewritten around `Map` insertion order — O(1) eviction (was O(n log n) per write once full) and true LRU semantics (was LFU).
17
+
3
18
  ## 1.0.1 - 2026-08-30
4
19
 
5
20
  Initial public release.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Blowfish (block cipher + CBC stream, NO padding).
3
+ *
4
+ * A correct, dependency-free replacement for `crypto.createDecipheriv('bf-cbc')`,
5
+ * which OpenSSL 3 / Node >= 17 disables by default (throws
6
+ * `ERR_OSSL_EVP_UNSUPPORTED`). Pure integer math, ~250 MiB/s.
7
+ *
8
+ * The initial P-array (18) and S-boxes (4 x 256) are the canonical Blowfish
9
+ * constants (the hexadecimal digits of pi), embedded below as one hex string.
10
+ * Verified against the standard Blowfish test vectors and against real Deezer
11
+ * FLAC/MP3 (see gerdur test-app / bench).
12
+ */
13
+ /// <reference types="node" />
14
+ export declare class Blowfish {
15
+ /** flat key: P at [0..17], S0 at [18..], S1 at [274..], S2 at [530..], S3 at [786..] */
16
+ private readonly k;
17
+ constructor(key: Buffer | Uint8Array);
18
+ private f;
19
+ private encBlock;
20
+ /**
21
+ * Decrypt `src[off .. off+len)` in CBC mode with the fixed Deezer IV and no
22
+ * padding, writing into `out` at the same offset. `len` must be a multiple of 8.
23
+ */
24
+ decryptCbc(src: Buffer, off: number, len: number, out: Buffer): void;
25
+ }
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ /**
3
+ * Blowfish (block cipher + CBC stream, NO padding).
4
+ *
5
+ * A correct, dependency-free replacement for `crypto.createDecipheriv('bf-cbc')`,
6
+ * which OpenSSL 3 / Node >= 17 disables by default (throws
7
+ * `ERR_OSSL_EVP_UNSUPPORTED`). Pure integer math, ~250 MiB/s.
8
+ *
9
+ * The initial P-array (18) and S-boxes (4 x 256) are the canonical Blowfish
10
+ * constants (the hexadecimal digits of pi), embedded below as one hex string.
11
+ * Verified against the standard Blowfish test vectors and against real Deezer
12
+ * FLAC/MP3 (see gerdur test-app / bench).
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.Blowfish = void 0;
16
+ // 1042 uint32 words = P[0..17] then S0[0..255], S1, S2, S3
17
+ const INIT_HEX = '243f6a8885a308d313198a2e03707344a4093822299f31d0082efa98ec4e6c89452821e638d01377be5466cf34e90c6cc0ac29b7c97c50dd3f84d5b5b54709179216d5d98979fb1bd1310ba698dfb5ac2ffd72dbd01adfb7b8e1afed6a267e96ba7c9045f12c7f9924a19947b3916cf70801f2e2858efc16636920d871574e69a458fea3f4933d7e0d95748f728eb658718bcd5882154aee7b54a41dc25a59b59c30d5392af26013c5d1b023286085f0ca417918b8db38ef8e79dcb0603a180e6c9e0e8bb01e8a3ed71577c1bd314b2778af2fda55605c60e65525f3aa55ab945748986263e8144055ca396a2aab10b6b4cc5c341141e8cea15486af7c72e993b3ee1411636fbc2a2ba9c55d741831f6ce5c3e169b87931eafd6ba336c24cf5c7a325381289586773b8f48986b4bb9afc4bfe81b6628219361d809ccfb21a991487cac605dec8032ef845d5de98575b1dc262302eb651b8823893e81d396acc50f6d6ff383f442392e0b4482a484200469c8f04a9e1f9b5e21c66842f6e96c9a670c9c61abd388f06a51a0d2d8542f68960fa728ab5133a36eef0b6c137a3be4ba3bf0507efb2a98a1f1651d39af017666ca593e82430e888cee8619456f9fb47d84a5c33b8b5ebee06f75d885c12073401a449f56c16aa64ed3aa62363f77061bfedf72429b023d37d0d724d00a1248db0fead349f1c09b075372c980991b7b25d479d8f6e8def7e3fe501ab6794c3b976ce0bd04c006bac1a94fb6409f60c45e5c9ec2196a246368fb6faf3e6c53b51339b2eb3b52ec6f6dfc511f9b30952ccc814544af5ebd09bee3d004de334afd660f2807192e4bb3c0cba85745c8740fd20b5f39b9d3fbdb5579c0bd1a60320ad6a100c6402c7279679f25fefb1fa3cc8ea5e9f8db3222f83c7516dffd616b152f501ec8ad0552ab323db5fafd23876053317b483e00df829e5c57bbca6f8ca01a87562edf1769dbd542a8f6287effc3ac6732c68c4f5573695b27b0bbca58c8e1ffa35db8f011a010fa3d98fd2183b84afcb56c2dd1d35b9a53e479b6f84565d28e49bc4bfb9790e1ddf2daa4cb7e3362fb1341cee4c6e8ef20cada36774c01d07e9efe2bf11fb495dbda4dae909198eaad8e716b93d5a0d08ed1d0afc725e08e3c5b2f8e7594b78ff6e2fbf2122b648888b812900df01c4fad5ea0688fc31cd1cff191b3a8c1ad2f2f2218be0e1777ea752dfe8b021fa1e5a0cc0fb56f74e818acf3d6ce89e299b4a84fe0fd13e0b77cc43b81d2ada8d9165fa2668095770593cc7314211a1477e6ad206577b5fa86c75442f5fb9d35cfebcdaf0c7b3e89a0d6411bd3ae1e7e4900250e2d2071b35e226800bb57b8e0af2464369bf009b91e5563911d59dfa6aa78c14389d95a537f207d5ba202e5b9c5832603766295cfa911c819684e734a41b3472dca7b14a94a1b5100529a532915d60f573fbc9bc6e42b60a47681e6740008ba6fb5571be91ff296ec6b2a0dd915b6636521e7b9f9b6ff34052ec585566453b02d5da99f8fa108ba47996e85076a4b7a70e9b5b32944db75092ec4192623ad6ea6b049a7df7d9cee60b88fedb266ecaa8c71699a17ff5664526cc2b19ee1193602a575094c29a0591340e4183a3e3f54989a5b429d656b8fe4d699f73fd6a1d29c07efe830f54d2d38e6f0255dc14cdd20868470eb266382e9c6021ecc5e09686b3f3ebaefc93c9718146b6a70a1687f358452a0e286b79c5305aa5007373e07841c7fdeae5c8e7d44ec5716f2b8b03ada37f0500c0df01c1f040200b3ffae0cf51a3cb574b225837a58dc0921bdd19113f97ca92ff69432477322f547013ae5e58137c2dadcc8b576349af3dda7a94461460fd0030eecc8c73ea4751e41e238cd993bea0e2f3280bba1183eb3314e548b384f6db9086f420d03f60a04bf2cb8129024977c795679b072bcaf89afde9a771fd9930810b38bae12dccf3f2e5512721f2e6b7124501adde69f84cd877a5847187408da17bc9f9abce94b7d8cec7aec3adb851dfa63094366c464c3d2ef1c18473215d908dd433b3724c2ba1612a14d432a65c45150940002133ae4dd71dff89e10314e5581ac77d65f11199b043556f1d7a3c76b3c11183b5924a509f28fe6ed97f1fbfa9ebabf2c1e153c6e86e34570eae96fb1860e5e0a5a3e2ab3771fe71c4e3d06fa2965dcb999e71d0f803e89d65266c8252e4cc9789c10b36ac6150eba94e2ea78a5fc3c531e0a2df4f2f74ea7361d2b3d1939260f19c279605223a708f71312b6ebadfe6eeac31f66e3bc4595a67bc883b17f37d1018cff28c332ddefbe6c5aa56558218568ab9802eecea50fdb2f953b2aef7dad5b6e2f841521b62829076170ecdd4775619f151013cca830eb61bd960334fe1eaa0363cfb5735c904c70a239d59e9e0bcbaade14eecc86bc60622ca79cab5cabb2f3846e648b1eaf19bdf0caa02369b9655abb5040685a323c2ab4b3319ee9d5c021b8f79b540b19875fa09995f7997e623d7da8f837889a97e32d7711ed935f166812810e358829c7e61fd696dedfa17858ba9957f584a51b2272639b83c3ff1ac24696cdb30aeb532e30548fd948e46dbc312858ebf2ef34c6ffeafe28ed61ee7c3c735d4a14d9e864b7e342105d14203e13e045eee2b6a3aaabeadb6c4f15facb4fd0c742f442ef6abbb5654f3b1d41cd2105d81e799e86854dc7e44b476a3d816250cf62a1f25b8d2646fc8883a0c1c7b6a37f1524c369cb749247848a0b5692b285095bbf00ad19489d1462b17423820e0058428d2a0c55f5ea1dadf43e233f70613372f0928d937e41d65fecf16c223bdb7cde3759cbee74604085f2a7ce77326ea607808419f8509ee8efd85561d99735a969a7aac50c06c25a04abfc800bcadc9e447a2ec3453484fdd567050e1e9ec9db73dbd3105588cd675fda79e3674340c5c43465713e38d83d28f89ef16dff20153e21e78fb03d4ae6e39f2bdb83adf7e93d5a68948140f7f64c261c94692934411520f77602d4f7bcf46b2ed4a20068d40824713320f46a43b7d4b7500061af1e39f62e9724454614214f74bf8b88404d95fc1d96b591af70f4ddd366a02f45bfbc09ec03bd97857fac6dd031cb850496eb27b355fd3941da2547e6abca0a9a28507825530429f40a2c86dae9b66dfb68dc1462d7486900680ec0a427a18dee4f3ffea2e887ad8cb58ce0067af4d6b6aace1e7cd3375fecce78a399406b2a4220fe9e35d9f385b9ee39d7ab3b124e8b1dc9faf74b6d185626a36631eae397b23a6efa74dd5b43326841e7f7ca7820fbfb0af54ed8feb397454056acba48952755533a3a20838d87fe6ba9b7d096954b55a867bca1159a58cca9296399e1db33a62a4a563f3125f95ef47e1c9029317cfdf8e80204272f7080bb155c05282ce395c11548e4c66d2248c1133fc70f86dc07f9c9ee41041f0f404779a45d886e17325f51ebd59bc0d1f2bcc18f41113564257b7834602a9c60dff8e8a31f636c1b0e12b4c202e1329eaf664fd1cad181156b2395e0333e92e13b240b62eebeb92285b2a20ee6ba0d99de720c8c2da2f728d012784595b794fd647d0862e7ccf5f05449a36f877d48fac39dfd27f33e8d1e0a476341992eff743a6f6eabf4f8fd37a812dc60a1ebddf8991be14cdb6e6b0dc67b55106d672c372765d43bdcd0e804f1290dc7cc00ffa3b5390f92690fed0b667b9ffbcedb7d9ca091cf0bd9155ea3bb132f88515bad247b9479bf763bd6eb37392eb3cc1159798026e297f42e312d6842ada7c66a2b3b12754ccc782ef11c6a124237b79251e706a1bbe64bfb63501a6b101811caedfa3d25bdd8e2e1c3c9444216590a121386d90cec6ed5abea2a64af674eda86a85fbebfe98864e4c3fe9dbc8057f0f7c08660787bf86003604dd1fd8346f6381fb07745ae04d736fccc83426b33f01eab71b08041873c005e5f77a057bebde8ae2455464299bf582e614e58f48ff2ddfda2f474ef388789bdc25366f9c3c8b38e74b475f25546fcd9b97aeb26618b1ddf84846a0e79915f95e2466e598e20b457708cd55591c902de4cb90bace1bb8205d011a862487574a99eb77f19b6e0a9dc09662d09a1c4324633e85a1f0209f0be8c4a99a0251d6efe101ab93d1d0ba5a4dfa186f20f2868f169dcb7da83573906fea1e2ce9b4fcd7f5250115e01a70683faa002b5c40de6d0279af88c27773f8641c3604c0661a806b5f0177a28c0f586e0006058aa30dc7d6211e69ed72338ea6353c2dd94c2c21634bbcbee5690bcb6deebfc7da1ce591d766f05e4094b7c018839720a3d7c927c2486e3725f724d9db91ac15bb4d39eb8fced54557808fca5b5d83d7cd34dad0fc41e50ef5eb161e6f8a28514d96c51133c6fd5c7e756e14ec4362abfceddc6c837d79a323492638212670efa8e406000e03a39ce37d3faf5cfabc277375ac52d1b5cb0679e4fa33742d382274099bc9bbed5118e9dbf0f7315d62d1c7ec700c47bb78c1b6b21a19045b26eb1be6a366eb45748ab2fbc946e79c6a376d26549c2c8530ff8ee468dde7dd5730a1d4cd04dc62939bbdba9ba4650ac9526e8be5ee304a1fad5f06a2d519a63ef8ce29a86ee22c089c2b843242ef6a51e03aa9cf2d0a483c061ba9be96a4d8fe51550ba645bd62826a2f9a73a3ae14ba99586ef5562e9c72fefd3f752f7da3f046f6977fa0a5980e4a91587b086019b09e6ad3b3ee593e990fd5a9e34d7972cf0b7d9022b8b5196d5ac3a017da67dd1cf3ed67c7d2d281f9f25cfadf2b89b5ad6b4725a88f54ce029ac71e019a5e647b0acfded93fa9be8d3c48d283b57ccf8d5662979132e28785f0191ed756055f7960e44e3d35e8c15056dd488f46dba03a161250564f0bdc3eb9e153c9057a297271aeca93a072a1b3f6d9b1e6321f5f59c66fb26dcf3197533d928b155fdf5035634828aba3cbb28517711c20ad9f8abcc5167ccad925f4de817513830dc8e379d58629320f991ea7a90c2fb3e7bce5121ce64774fbe32a8b6e37ec3293d4648de53696413e680a2ae0810dd6db22469852dfd09072166b39a460a6445c0dd586cdecf1c20c8ae5bbef7dd1b588d40ccd2017f6bb4e3bbdda26a7e3a59ff453e350a44bcb4cdd572eacea8fa6484bb8d6612aebf3c6f47d29be463542f5d9eaec2771bf64e6370740e0d8de75b1357f8721671af537d5d4040cb084eb4e2cc34d2466a0115af84e1b0042895983a1d06b89fb4ce6ea0486f3f3b823520ab82011a1d4b277227f8611560b1e7933fdcbb3a792b344525bda08839e151ce794b2f32c9b7a01fbac9e01cc87ebcc7d1f6cf0111c3a1e8aac71a908749d44fbd9ad0dadecbd50ada380339c32ac69136678df9317ce0b12b4ff79e59b743f5bb3af2d519ff27d9459cbf97222c15e6fc2a0f91fc719b941525fae59361ceb69cebc2a8645912baa8d1b6c1075ee3056a0c10d25065cb03a442e0ec6e0e1698db3b4c98a0be3278e9649f1f9532e0d392dfd3a0342b8971f21e1b0a74414ba3348cc5be7120c37632d8df359f8d9b992f2ee60b6f470fe3f11de54cda541edad891ce6279cfcd3e7e6f1618b166fd2c1d05848fd2c5f6fb2299f523f357a632762393a8353156cccd02acf081625a75ebb56e16369788d273ccde96629281b949d04c50901b71c65614e6c6c7bd327a140a45e1d006c3f27b9ac9aa53fd62a80f00bb25bfe235bdd2f671126905b2040222b6cbcf7ccd769c2b53113ec01640e3d338abbd602547adf0ba38209cf746ce7677afa1c52075606085cbfe4e8ae88dd87aaaf9b04cf9aa7e1948c25c02fb8a8c01c36ae4d6ebe1f990d4f869a65cdea03f09252dc208e69fb74e6132ce77e25b578fdfe33ac372e6';
18
+ const INIT = new Uint32Array(1042);
19
+ for (let i = 0; i < 1042; i++) {
20
+ INIT[i] = parseInt(INIT_HEX.slice(i * 8, i * 8 + 8), 16) >>> 0;
21
+ }
22
+ const IV0 = 0x00010203 | 0;
23
+ const IV1 = 0x04050607 | 0;
24
+ class Blowfish {
25
+ constructor(key) {
26
+ /** flat key: P at [0..17], S0 at [18..], S1 at [274..], S2 at [530..], S3 at [786..] */
27
+ this.k = new Uint32Array(1042);
28
+ const k = this.k;
29
+ k.set(INIT);
30
+ let j = 0;
31
+ for (let i = 0; i < 18; i++) {
32
+ let d = 0;
33
+ for (let n = 0; n < 4; n++) {
34
+ d = ((d << 8) | key[j % key.length]) >>> 0;
35
+ j++;
36
+ }
37
+ k[i] = (k[i] ^ d) >>> 0;
38
+ }
39
+ let l = 0;
40
+ let r = 0;
41
+ for (let i = 0; i < 18; i += 2) {
42
+ [l, r] = this.encBlock(l, r);
43
+ k[i] = l >>> 0;
44
+ k[i + 1] = r >>> 0;
45
+ }
46
+ for (let s = 0; s < 4; s++) {
47
+ const base = 18 + s * 256;
48
+ for (let i = 0; i < 256; i += 2) {
49
+ [l, r] = this.encBlock(l, r);
50
+ k[base + i] = l >>> 0;
51
+ k[base + i + 1] = r >>> 0;
52
+ }
53
+ }
54
+ }
55
+ f(x) {
56
+ const k = this.k;
57
+ const a = (k[18 + ((x >>> 24) & 0xff)] + k[274 + ((x >>> 16) & 0xff)]) | 0;
58
+ return (((a ^ k[530 + ((x >>> 8) & 0xff)]) | 0) + k[786 + (x & 0xff)]) | 0;
59
+ }
60
+ encBlock(l, r) {
61
+ const k = this.k;
62
+ for (let i = 0; i < 16; i++) {
63
+ l = (l ^ k[i]) | 0;
64
+ r = (r ^ this.f(l)) | 0;
65
+ const t = l;
66
+ l = r;
67
+ r = t;
68
+ }
69
+ const t = l;
70
+ l = r;
71
+ r = t;
72
+ r = (r ^ k[16]) | 0;
73
+ l = (l ^ k[17]) | 0;
74
+ return [l >>> 0, r >>> 0];
75
+ }
76
+ /**
77
+ * Decrypt `src[off .. off+len)` in CBC mode with the fixed Deezer IV and no
78
+ * padding, writing into `out` at the same offset. `len` must be a multiple of 8.
79
+ */
80
+ decryptCbc(src, off, len, out) {
81
+ const k = this.k;
82
+ let pl = IV0;
83
+ let pr = IV1;
84
+ const end = off + len;
85
+ for (let p = off; p < end; p += 8) {
86
+ const cl = (src[p] << 24) | (src[p + 1] << 16) | (src[p + 2] << 8) | src[p + 3] | 0;
87
+ const cr = (src[p + 4] << 24) | (src[p + 5] << 16) | (src[p + 6] << 8) | src[p + 7] | 0;
88
+ let L = cl;
89
+ let R = cr;
90
+ for (let i = 17; i > 1; i -= 2) {
91
+ L = (L ^ k[i]) | 0;
92
+ R = (R ^ this.f(L)) | 0;
93
+ R = (R ^ k[i - 1]) | 0;
94
+ L = (L ^ this.f(R)) | 0;
95
+ }
96
+ const left = ((R ^ k[0]) >>> 0) ^ (pl >>> 0);
97
+ const right = ((L ^ k[1]) >>> 0) ^ (pr >>> 0);
98
+ out[p] = (left >>> 24) & 0xff;
99
+ out[p + 1] = (left >>> 16) & 0xff;
100
+ out[p + 2] = (left >>> 8) & 0xff;
101
+ out[p + 3] = left & 0xff;
102
+ out[p + 4] = (right >>> 24) & 0xff;
103
+ out[p + 5] = (right >>> 16) & 0xff;
104
+ out[p + 6] = (right >>> 8) & 0xff;
105
+ out[p + 7] = right & 0xff;
106
+ pl = cl;
107
+ pr = cr;
108
+ }
109
+ }
110
+ }
111
+ exports.Blowfish = Blowfish;
@@ -2,8 +2,26 @@
2
2
  import type { trackType } from '../types';
3
3
  export declare const getSongFileName: ({ MD5_ORIGIN, SNG_ID, MEDIA_VERSION }: trackType, quality: number) => string;
4
4
  /**
5
+ * Decrypt a downloaded track. Deezer applies Blowfish-CBC "stripe" obfuscation:
6
+ * the file is split into 2048-byte chunks and only every third chunk (0, 3, 6…)
7
+ * is encrypted; the rest — and any trailing partial chunk — are plaintext.
5
8
  *
6
- * @param source Downloaded song from `getTrackDownloadUrl`
7
- * @param trackId Song ID as string
9
+ * @param source Downloaded body from `getTrackDownloadUrl`
10
+ * @param trackId `SNG_ID` as a string
8
11
  */
9
12
  export declare const decryptDownload: (source: Buffer, trackId: string) => Buffer;
13
+ /**
14
+ * Streaming variant: feed it socket chunks in order via `write()`, then call
15
+ * `final()`. Decrypts each 2048-byte stripe as it completes — constant memory,
16
+ * and the CPU work hides behind the (slower) network read.
17
+ */
18
+ export declare class TrackDecryptStream {
19
+ private readonly bf;
20
+ private carry;
21
+ private chunkIndex;
22
+ constructor(trackId: string);
23
+ /** Returns the decrypted bytes for every complete 2048-byte stripe now available. */
24
+ write(part: Buffer): Buffer;
25
+ /** The trailing partial chunk (always plaintext). */
26
+ final(): Buffer;
27
+ }
@@ -3,8 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.decryptDownload = exports.getSongFileName = void 0;
6
+ exports.TrackDecryptStream = exports.decryptDownload = exports.getSongFileName = void 0;
7
7
  const crypto_1 = __importDefault(require("crypto"));
8
+ const blowfish_1 = require("./blowfish");
8
9
  const md5 = (data, type = 'ascii') => {
9
10
  const md5sum = crypto_1.default.createHash('md5');
10
11
  md5sum.update(data.toString(), type);
@@ -21,43 +22,81 @@ const getSongFileName = ({ MD5_ORIGIN, SNG_ID, MEDIA_VERSION }, quality) => {
21
22
  return crypto_1.default.createCipheriv('aes-128-ecb', 'jo6aey6haid2Teih', '').update(step2, 'ascii', 'hex');
22
23
  };
23
24
  exports.getSongFileName = getSongFileName;
25
+ const BLOWFISH_SECRET = 'g4el58wc0zvf9na1';
26
+ /** Per-track Blowfish key: md5(id)[i] ^ md5(id)[i+16] ^ SECRET[i], for i in 0..15. */
24
27
  const getBlowfishKey = (trackId) => {
25
- const SECRET = 'g4el58wc' + '0zvf9na1';
26
28
  const idMd5 = md5(trackId);
27
- let bfKey = '';
29
+ const key = Buffer.allocUnsafe(16);
28
30
  for (let i = 0; i < 16; i++) {
29
- bfKey += String.fromCharCode(idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ SECRET.charCodeAt(i));
31
+ key[i] = idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ BLOWFISH_SECRET.charCodeAt(i);
30
32
  }
31
- return bfKey;
32
- };
33
- const decryptChunk = (chunk, blowFishKey) => {
34
- const cipher = crypto_1.default.createDecipheriv('bf-cbc', blowFishKey, Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]));
35
- cipher.setAutoPadding(false);
36
- return Buffer.concat([cipher.update(chunk), cipher.final()]);
33
+ return key;
37
34
  };
35
+ const CHUNK = 2048;
38
36
  /**
37
+ * Decrypt a downloaded track. Deezer applies Blowfish-CBC "stripe" obfuscation:
38
+ * the file is split into 2048-byte chunks and only every third chunk (0, 3, 6…)
39
+ * is encrypted; the rest — and any trailing partial chunk — are plaintext.
39
40
  *
40
- * @param source Downloaded song from `getTrackDownloadUrl`
41
- * @param trackId Song ID as string
41
+ * @param source Downloaded body from `getTrackDownloadUrl`
42
+ * @param trackId `SNG_ID` as a string
42
43
  */
43
44
  const decryptDownload = (source, trackId) => {
44
- const chunkSize = 2048;
45
- const blowFishKey = getBlowfishKey(trackId);
46
- let chunkIndex = 0;
45
+ const bf = new blowfish_1.Blowfish(getBlowfishKey(trackId));
46
+ const dest = Buffer.allocUnsafe(source.length);
47
47
  let position = 0;
48
- const destBuffer = Buffer.alloc(source.length);
48
+ let chunkIndex = 0;
49
49
  while (position < source.length) {
50
- const currentChunkSize = Math.min(chunkSize, source.length - position);
51
- const sourceChunk = source.subarray(position, position + currentChunkSize);
52
- if (chunkIndex % 3 > 0 || currentChunkSize < chunkSize) {
53
- sourceChunk.copy(destBuffer, position);
50
+ const size = Math.min(CHUNK, source.length - position);
51
+ if (chunkIndex % 3 === 0 && size === CHUNK) {
52
+ bf.decryptCbc(source, position, CHUNK, dest);
54
53
  }
55
54
  else {
56
- decryptChunk(sourceChunk, blowFishKey).copy(destBuffer, position);
55
+ source.copy(dest, position, position, position + size);
57
56
  }
58
- position += currentChunkSize;
57
+ position += size;
59
58
  chunkIndex++;
60
59
  }
61
- return destBuffer;
60
+ return dest;
62
61
  };
63
62
  exports.decryptDownload = decryptDownload;
63
+ /**
64
+ * Streaming variant: feed it socket chunks in order via `write()`, then call
65
+ * `final()`. Decrypts each 2048-byte stripe as it completes — constant memory,
66
+ * and the CPU work hides behind the (slower) network read.
67
+ */
68
+ class TrackDecryptStream {
69
+ constructor(trackId) {
70
+ this.carry = Buffer.alloc(0);
71
+ this.chunkIndex = 0;
72
+ this.bf = new blowfish_1.Blowfish(getBlowfishKey(trackId));
73
+ }
74
+ /** Returns the decrypted bytes for every complete 2048-byte stripe now available. */
75
+ write(part) {
76
+ const data = this.carry.length ? Buffer.concat([this.carry, part]) : part;
77
+ const complete = data.length - (data.length % CHUNK);
78
+ if (complete === 0) {
79
+ this.carry = data;
80
+ return Buffer.alloc(0);
81
+ }
82
+ const out = Buffer.allocUnsafe(complete);
83
+ for (let off = 0; off < complete; off += CHUNK) {
84
+ if (this.chunkIndex % 3 === 0) {
85
+ this.bf.decryptCbc(data, off, CHUNK, out);
86
+ }
87
+ else {
88
+ data.copy(out, off, off, off + CHUNK);
89
+ }
90
+ this.chunkIndex++;
91
+ }
92
+ this.carry = data.subarray(complete);
93
+ return out;
94
+ }
95
+ /** The trailing partial chunk (always plaintext). */
96
+ final() {
97
+ const rest = this.carry;
98
+ this.carry = Buffer.alloc(0);
99
+ return rest;
100
+ }
101
+ }
102
+ exports.TrackDecryptStream = TrackDecryptStream;
@@ -1,63 +1,21 @@
1
- /**
2
- * Fast LRU & TTL cache
3
- * @param {Integer} options.max - Max entries in the cache. @default Infinity
4
- * @param {Integer} options.ttl - Timeout before removing entries. @default Infinity
5
- */
6
1
  declare class FastLRU {
7
- _max: number;
8
- _ttl: number;
9
- _cache: Map<any, any>;
10
- _meta: {
11
- [key: string]: any;
12
- };
13
- constructor({ maxSize, ttl }: {
14
- maxSize?: number | undefined;
15
- ttl?: number | undefined;
2
+ private readonly max;
3
+ private readonly ttl;
4
+ private readonly map;
5
+ constructor({ maxSize, ttl }?: {
6
+ maxSize?: number;
7
+ ttl?: number;
16
8
  });
17
- /**
18
- * Add new entry
19
- */
20
- set(key: string, value: any, ttl?: number): void;
21
- /**
22
- * Get entry
23
- */
24
9
  get(key: string): any;
25
- /**
26
- * Get without hitting hits
27
- */
28
10
  peek(key: string): any;
29
- /**
30
- * Remove entry
31
- */
11
+ set(key: string, value: any, ttl?: number): void;
32
12
  delete(key: string): void;
33
- /**
34
- * Remove all entries
35
- */
36
13
  clear(): void;
37
- /**
38
- * Check has entry
39
- */
40
14
  has(key: string): boolean;
41
- /**
42
- * Get all kies
43
- * @returns {Iterator} Iterator on all kies
44
- */
45
- keys(): IterableIterator<any>;
46
- /**
47
- * Iterate over values
48
- */
49
- values(): IterableIterator<any>;
50
- /**
51
- * Iterate over entries
52
- */
53
- entries(): IterableIterator<[any, any]>;
54
- /**
55
- * For each
56
- */
57
- forEach(cb: any): void;
58
- /**
59
- * Entries total size
60
- */
15
+ keys(): IterableIterator<string>;
16
+ values(): IterableIterator<unknown>;
17
+ entries(): IterableIterator<[string, unknown]>;
18
+ forEach(cb: (value: unknown, key: string) => void): void;
61
19
  get size(): number;
62
20
  }
63
21
  export default FastLRU;
@@ -1,121 +1,75 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- /**
4
- * Fast LRU & TTL cache
5
- * @param {Integer} options.max - Max entries in the cache. @default Infinity
6
- * @param {Integer} options.ttl - Timeout before removing entries. @default Infinity
7
- */
8
3
  class FastLRU {
9
- constructor({ maxSize = Infinity, ttl = 0 }) {
10
- // Default options
11
- this._max = maxSize;
12
- this._ttl = ttl;
13
- this._cache = new Map();
14
- // Metadata for entries
15
- this._meta = {};
4
+ constructor({ maxSize = Infinity, ttl = 0 } = {}) {
5
+ this.map = new Map();
6
+ this.max = maxSize;
7
+ this.ttl = ttl;
16
8
  }
17
- /**
18
- * Add new entry
19
- */
20
- set(key, value, ttl = this._ttl) {
21
- // Execution time
22
- const time = Date.now();
23
- // Remvove least recently used elements if exceeds max bytes
24
- if (this._cache.size >= this._max) {
25
- const items = Object.values(this._meta);
26
- if (this._ttl > 0) {
27
- for (const item of items) {
28
- if (item.expire < time) {
29
- this.delete(item.key);
30
- }
31
- }
32
- }
33
- if (this._cache.size >= this._max) {
34
- const least = items.sort((a, b) => a.hits - b.hits)[0];
35
- this.delete(least.key);
36
- }
9
+ get(key) {
10
+ const e = this.map.get(key);
11
+ if (e === undefined)
12
+ return undefined;
13
+ if (e.expire !== 0 && e.expire < Date.now()) {
14
+ this.map.delete(key);
15
+ return undefined;
37
16
  }
38
- // Override if key already set
39
- this._cache.set(key, value);
40
- this._meta[key] = {
41
- key,
42
- hits: 0,
43
- expire: time + ttl,
44
- };
17
+ // bump to most-recently-used
18
+ this.map.delete(key);
19
+ this.map.set(key, e);
20
+ return e.value;
45
21
  }
46
- /**
47
- * Get entry
48
- */
49
- get(key) {
50
- if (this._cache.has(key)) {
51
- const item = this._cache.get(key);
52
- if (this._ttl > 0) {
53
- const time = Date.now();
54
- if (this._meta[key].expire < time) {
55
- this.delete(key);
56
- return undefined;
22
+ peek(key) {
23
+ var _a;
24
+ return (_a = this.map.get(key)) === null || _a === void 0 ? void 0 : _a.value;
25
+ }
26
+ set(key, value, ttl = this.ttl) {
27
+ this.map.delete(key);
28
+ if (this.map.size >= this.max) {
29
+ // drop expired first, then the LRU head if still over
30
+ if (this.ttl > 0) {
31
+ const now = Date.now();
32
+ for (const [k, e] of this.map) {
33
+ if (e.expire !== 0 && e.expire < now)
34
+ this.map.delete(k);
57
35
  }
58
36
  }
59
- this._meta[key].hits++;
60
- return item;
37
+ while (this.map.size >= this.max) {
38
+ const oldest = this.map.keys().next().value;
39
+ if (oldest === undefined)
40
+ break;
41
+ this.map.delete(oldest);
42
+ }
61
43
  }
44
+ this.map.set(key, { value, expire: ttl > 0 ? Date.now() + ttl : 0 });
62
45
  }
63
- /**
64
- * Get without hitting hits
65
- */
66
- peek(key) {
67
- return this._cache.get(key);
68
- }
69
- /**
70
- * Remove entry
71
- */
72
46
  delete(key) {
73
- delete this._meta[key];
74
- this._cache.delete(key);
47
+ this.map.delete(key);
75
48
  }
76
- /**
77
- * Remove all entries
78
- */
79
49
  clear() {
80
- this._cache.clear();
81
- this._meta = {};
50
+ this.map.clear();
82
51
  }
83
- /**
84
- * Check has entry
85
- */
86
52
  has(key) {
87
- return this._cache.has(key);
53
+ const e = this.map.get(key);
54
+ return e !== undefined && (e.expire === 0 || e.expire >= Date.now());
88
55
  }
89
- /**
90
- * Get all kies
91
- * @returns {Iterator} Iterator on all kies
92
- */
93
56
  keys() {
94
- return this._cache.keys();
57
+ return this.map.keys();
95
58
  }
96
- /**
97
- * Iterate over values
98
- */
99
- values() {
100
- return this._cache.values();
59
+ *values() {
60
+ for (const e of this.map.values())
61
+ yield e.value;
101
62
  }
102
- /**
103
- * Iterate over entries
104
- */
105
- entries() {
106
- return this._cache.entries();
63
+ *entries() {
64
+ for (const [k, e] of this.map)
65
+ yield [k, e.value];
107
66
  }
108
- /**
109
- * For each
110
- */
111
67
  forEach(cb) {
112
- return this._cache.forEach(cb);
68
+ for (const [k, e] of this.map)
69
+ cb(e.value, k);
113
70
  }
114
- /**
115
- * Entries total size
116
- */
117
71
  get size() {
118
- return this._cache.size;
72
+ return this.map.size;
119
73
  }
120
74
  }
121
75
  exports.default = FastLRU;
@@ -5,6 +5,14 @@ export declare class WrongLicense extends Error {
5
5
  export declare class GeoBlocked extends Error {
6
6
  constructor(country: string);
7
7
  }
8
+ /**
9
+ * The track's `TRACK_TOKEN` has expired (they last ~1 hour). Re-fetch the track
10
+ * with `getTrackInfo(SNG_ID)` for a fresh token and retry.
11
+ */
12
+ export declare class ExpiredTrackToken extends Error {
13
+ readonly sngId: string;
14
+ constructor(sngId: string);
15
+ }
8
16
  /**
9
17
  * @param track Track info json returned from `getTrackInfo`
10
18
  * @param quality 1 = 128kbps, 3 = 320kbps and 9 = flac (around 1411kbps)
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getTrackDownloadUrl = exports.GeoBlocked = exports.WrongLicense = void 0;
6
+ exports.getTrackDownloadUrl = exports.ExpiredTrackToken = exports.GeoBlocked = exports.WrongLicense = void 0;
7
7
  const decrypt_1 = require("../lib/decrypt");
8
8
  const http_1 = require("../lib/http");
9
9
  const request_1 = __importDefault(require("../lib/request"));
@@ -23,6 +23,18 @@ class GeoBlocked extends Error {
23
23
  }
24
24
  }
25
25
  exports.GeoBlocked = GeoBlocked;
26
+ /**
27
+ * The track's `TRACK_TOKEN` has expired (they last ~1 hour). Re-fetch the track
28
+ * with `getTrackInfo(SNG_ID)` for a fresh token and retry.
29
+ */
30
+ class ExpiredTrackToken extends Error {
31
+ constructor(sngId) {
32
+ super(`Track token for ${sngId} has expired — re-fetch the track and retry`);
33
+ this.sngId = sngId;
34
+ this.name = 'ExpiredTrackToken';
35
+ }
36
+ }
37
+ exports.ExpiredTrackToken = ExpiredTrackToken;
26
38
  let user_data = null;
27
39
  const getTrackFileSize = (track, quality) => {
28
40
  switch (quality) {
@@ -69,9 +81,14 @@ const getTrackUrlFromServer = async (track_token, format) => {
69
81
  });
70
82
  if (data.data.length > 0) {
71
83
  if (data.data[0].errors) {
72
- if (data.data[0].errors[0].code === 2002) {
84
+ const { code } = data.data[0].errors[0];
85
+ if (code === 2002) {
73
86
  throw new GeoBlocked(user.country);
74
87
  }
88
+ // 2000: invalid/expired token · 2001: token has no rights on this song
89
+ if (code === 2000 || code === 2001) {
90
+ throw new ExpiredTrackToken(track_token);
91
+ }
75
92
  throw new Error(Object.entries(data.data[0].errors[0]).join(', '));
76
93
  }
77
94
  return data.data[0].media.length > 0 ? data.data[0].media[0].sources[0].url : null;
@@ -85,6 +102,7 @@ const getTrackUrlFromServer = async (track_token, format) => {
85
102
  const getTrackDownloadUrl = async (track, quality) => {
86
103
  let wrongLicense = null;
87
104
  let geoBlocked = null;
105
+ let expiredToken = null;
88
106
  let formatName;
89
107
  switch (quality) {
90
108
  case 9:
@@ -99,26 +117,37 @@ const getTrackDownloadUrl = async (track, quality) => {
99
117
  default:
100
118
  throw new Error(`Unknown quality ${quality}`);
101
119
  }
102
- // Get URL with the official API
103
- try {
104
- const url = await getTrackUrlFromServer(track.TRACK_TOKEN, formatName);
105
- if (url) {
106
- return {
107
- trackUrl: url,
108
- isEncrypted: url.includes('/mobile/') || url.includes('/media/'),
109
- fileSize: getTrackFileSize(track, quality),
110
- };
111
- }
120
+ // Track tokens last ~1 hour. If it's already stale, skip the doomed media API
121
+ // call and go straight to the token-free fallback below.
122
+ const tokenStale = Boolean(track.TRACK_TOKEN_EXPIRE && track.TRACK_TOKEN_EXPIRE * 1000 < Date.now());
123
+ if (tokenStale) {
124
+ expiredToken = new ExpiredTrackToken(track.SNG_ID);
112
125
  }
113
- catch (err) {
114
- if (err instanceof WrongLicense) {
115
- wrongLicense = err;
116
- }
117
- else if (err instanceof GeoBlocked) {
118
- geoBlocked = err;
126
+ else {
127
+ // Get URL with the official API
128
+ try {
129
+ const url = await getTrackUrlFromServer(track.TRACK_TOKEN, formatName);
130
+ if (url) {
131
+ return {
132
+ trackUrl: url,
133
+ isEncrypted: url.includes('/mobile/') || url.includes('/media/'),
134
+ fileSize: getTrackFileSize(track, quality),
135
+ };
136
+ }
119
137
  }
120
- else {
121
- throw err;
138
+ catch (err) {
139
+ if (err instanceof WrongLicense) {
140
+ wrongLicense = err;
141
+ }
142
+ else if (err instanceof GeoBlocked) {
143
+ geoBlocked = err;
144
+ }
145
+ else if (err instanceof ExpiredTrackToken) {
146
+ expiredToken = err;
147
+ }
148
+ else {
149
+ throw err;
150
+ }
122
151
  }
123
152
  }
124
153
  // Fallback to the old method
@@ -140,6 +169,9 @@ const getTrackDownloadUrl = async (track, quality) => {
140
169
  if (geoBlocked) {
141
170
  throw geoBlocked;
142
171
  }
172
+ if (expiredToken) {
173
+ throw expiredToken;
174
+ }
143
175
  return null;
144
176
  };
145
177
  exports.getTrackDownloadUrl = getTrackDownloadUrl;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Core module for gerdur.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",