PluginProbe
CryptX / 4.2.1
CryptX v4.2.1
4.2.1 4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 All 93 releases
← All changes | js/cryptx.js +72 -16 4.1.14.2.1 View file →
@@ -14,9 +14,22 @@
14 14 */
15 15 (function () {
16 16
17 17 // Configuration constants
18 -const ITERATIONS = window.cryptxConfig?.iterations || 100000; // fallback to old value
18 +// The ceiling PHP already enforces (SecureEncryption::MAX_ITERATIONS), repeated
19 +// here so that no path can hand an absurd number to PBKDF2 and leave a
20 +// visitor's browser tab grinding.
21 +const MAX_ROUNDS = 1000000;
22 +
23 +// parseInt, because wp_localize_script turns every value into a string on the
24 +// way into the page -- so cryptxConfig.iterations arrives as "10000", and a
25 +// string fails Number.isInteger. Without this the clamp below applied only to
26 +// the value read from a link and never to this one, which is the opposite of
27 +// what one would assume from reading it.
28 +const ITERATIONS = Math.min(
29 + parseInt(window.cryptxConfig?.iterations, 10) || 100000, // fallback to old value
30 + MAX_ROUNDS
31 +);
19 32 const KEY_LENGTH = window.cryptxConfig?.keyLength || 32;
20 33 const IV_LENGTH = window.cryptxConfig?.ivLength || 16;
21 34 const SALT_LENGTH = window.cryptxConfig?.saltLength || 16;
22 35 const CONFIG = {
@@ -223,9 +236,25 @@
223 236 /**
224 237 * Modern encryption class using Web Crypto API - PHP Compatible
225 238 */
226 239 class SecureEncryption {
227 - static async deriveKey(password, salt) {
240 + /**
241 + * @param {string} password
242 + * @param {Uint8Array} salt
243 + * @param {number} [iterations] What the link itself says it was made with.
244 + * Left out only by links written before 4.2.0, which then fall back to
245 + * the configured value -- the behaviour that made changing the setting
246 + * kill every link already delivered.
247 + */
248 + static async deriveKey(password, salt, iterations) {
249 + // Clamped to the same ceiling PHP enforces. The value can only come
250 + // from the server today -- KSES lets neither class nor data-* through
251 + // for anyone without unfiltered_html, and anyone with it does not need
252 + // this route -- but a number that reaches PBKDF2 unchecked is worth one
253 + // line of arithmetic.
254 + const rounds = Number.isInteger(iterations) && iterations > 0
255 + ? Math.min(iterations, MAX_ROUNDS)
256 + : ITERATIONS;
228 257 const encoder = new TextEncoder();
229 258 const keyMaterial = await crypto.subtle.importKey(
230 259 'raw',
231 260 encoder.encode(password),
@@ -237,9 +266,9 @@
237 266 return crypto.subtle.deriveKey(
238 267 {
239 268 name: 'PBKDF2',
240 269 salt: salt,
241 - iterations: ITERATIONS,
270 + iterations: rounds,
242 271 hash: 'SHA-256'
243 272 },
244 273 keyMaterial,
245 274 { name: 'AES-GCM', length: KEY_LENGTH * 8},
@@ -278,9 +307,14 @@
278 307
279 308 return SecureUtils.arrayBufferToBase64(combined.buffer);
280 309 }
281 310
282 - static async decrypt(encryptedData, password) {
311 + /**
312 + * @param {string} encryptedData
313 + * @param {string} password
314 + * @param {number} [iterations] See deriveKey().
315 + */
316 + static async decrypt(encryptedData, password, iterations) {
283 317 if (typeof encryptedData !== 'string' || typeof password !== 'string') {
284 318 throw new Error('Both encryptedData and password must be strings');
285 319 }
286 320
@@ -302,9 +336,9 @@
302 336 const iv = combined.slice(saltLength, saltLength + ivLength);
303 337 const encryptedDataOnly = combined.slice(saltLength + ivLength, saltLength + ivLength + encryptedDataLength);
304 338 const tag = combined.slice(-tagLength); // Last 16 bytes
305 339
306 - const key = await this.deriveKey(password, new Uint8Array(salt));
340 + const key = await this.deriveKey(password, new Uint8Array(salt), iterations);
307 341
308 342 // Reconstruct the encrypted data with tag for Web Crypto API
309 343 const encryptedWithTag = new Uint8Array(encryptedDataOnly.byteLength + tag.byteLength);
310 344 encryptedWithTag.set(new Uint8Array(encryptedDataOnly), 0);
@@ -332,9 +366,9 @@
332 366 * Securely decrypts and validates a URL before navigation
333 367 * @param {string} encryptedUrl
334 368 * @param {string} password
335 369 */
336 -async function secureDecryptAndNavigate(encryptedUrl, password = 'default_key') {
370 +async function secureDecryptAndNavigate(encryptedUrl, password = 'default_key', iterations) {
337 371 if (typeof encryptedUrl !== 'string' || encryptedUrl.length === 0) {
338 372 console.error('Invalid encrypted URL provided');
339 373 return;
340 374 }
@@ -343,9 +377,9 @@
343 377 let decryptedUrl;
344 378
345 379 // Try modern decryption first, then fall back to original algorithm
346 380 try {
347 - decryptedUrl = await SecureEncryption.decrypt(encryptedUrl, password);
381 + decryptedUrl = await SecureEncryption.decrypt(encryptedUrl, password, iterations);
348 382 } catch (modernError) {
349 383 console.warn('Modern decryption failed, trying original algorithm');
350 384 decryptedUrl = LegacyEncryption.originalDecrypt(encryptedUrl);
351 385 }
@@ -416,9 +450,15 @@
416 450 const mailtoUrl = `mailto:${emailAddress}`;
417 451 const encryptedData = await SecureEncryption.encrypt(mailtoUrl, password);
418 452 const escapedData = SecureUtils.escapeJavaScript(encryptedData);
419 453
420 - return `javascript:secureDecryptAndNavigate('${escapedData}', '${SecureUtils.escapeJavaScript(password)}')`;
454 + // The iteration count goes in as well, for the same reason the PHP side
455 + // puts it in data-cxi: encrypt() used whatever ITERATIONS says right now,
456 + // and without recording that, a later change to the setting would leave
457 + // this link unopenable. Nothing in the plugin calls this function -- it is
458 + // here for anyone building links themselves -- which is exactly why it
459 + // should not be the one place that still breaks.
460 + return `javascript:secureDecryptAndNavigate('${escapedData}', '${SecureUtils.escapeJavaScript(password)}', ${ITERATIONS})`;
421 461 }
422 462
423 463 /**
424 464 * Legacy function for backward compatibility - using original algorithm
@@ -460,9 +500,9 @@
460 500 *
461 501 * Markup produced by the PHP side (no javascript: URI, therefore no
462 502 * 'unsafe-inline' needed in the Content-Security-Policy):
463 503 *
464 - * <a href="#" class="cryptx-link" data-cx="BASE64" data-cxk="PASSWORD" data-cxm="secure">…</a>
504 + * <a href="#" class="cryptx-link" data-cx="BASE64" data-cxk="PASSWORD" data-cxm="secure" data-cxi="10000">…</a>
465 505 * <a href="#" class="cryptx-link" data-cx="0i2p2h…" data-cxm="legacy">…</a>
466 506 *
467 507 * A single delegated listener on `document` covers links that are added later
468 508 * (widgets, AJAX, block editor preview). The legacy javascript: entry points
@@ -472,12 +512,11 @@
472 512 const CRYPTX_LINK_CLASS = 'cryptx-link';
473 513 const CRYPTX_ATTR_PAYLOAD = 'data-cx';
474 514 const CRYPTX_ATTR_KEY = 'data-cxk';
475 515 const CRYPTX_ATTR_MODE = 'data-cxm';
516 +const CRYPTX_ATTR_ITERATIONS = 'data-cxi';
476 517 const CRYPTX_MAX_DELEGATION_DEPTH = 50;
477 518
478 -let cryptxLinkHandlerAttached = false;
479 -
480 519 /**
481 520 * True only when the Web Crypto API is usable (secure context, modern browser)
482 521 * @returns {boolean}
483 522 */
@@ -535,11 +574,14 @@
535 574 * exactly like secureDecryptAndNavigate() does.
536 575 * @param {string} payload
537 576 * @param {string|null} password
538 577 * @param {string|null} mode
578 + * @param {string|number|null} [iterations] What data-cxi says. Links written
579 + * before 4.2.0 do not carry it and fall back to the configured value -- which
580 + * is why changing that value used to break every link already delivered.
539 581 * @returns {Promise<string>}
540 582 */
541 -async function cryptxDecryptPayload(payload, password, mode) {
583 +async function cryptxDecryptPayload(payload, password, mode, iterations) {
542 584 if (typeof payload !== 'string' || payload.length === 0) {
543 585 throw new Error('Missing or invalid data-cx payload');
544 586 }
545 587
@@ -556,11 +598,12 @@
556 598 return LegacyEncryption.originalDecrypt(payload);
557 599 }
558 600
559 601 const key = typeof password === 'string' && password.length > 0 ? password : 'default_key';
602 + const rounds = parseInt(iterations, 10);
560 603
561 604 try {
562 - return await SecureEncryption.decrypt(payload, key);
605 + return await SecureEncryption.decrypt(payload, key, rounds);
563 606 } catch (secureError) {
564 607 if (normalizedMode === 'secure') {
565 608 throw secureError;
566 609 }
@@ -591,11 +634,12 @@
591 634
592 635 const payload = link.getAttribute(CRYPTX_ATTR_PAYLOAD);
593 636 const password = link.getAttribute(CRYPTX_ATTR_KEY);
594 637 const mode = link.getAttribute(CRYPTX_ATTR_MODE);
638 + const iterations = link.getAttribute(CRYPTX_ATTR_ITERATIONS);
595 639
596 640 try {
597 - const decryptedUrl = await cryptxDecryptPayload(payload, password, mode);
641 + const decryptedUrl = await cryptxDecryptPayload(payload, password, mode, iterations);
598 642
599 643 const validatedUrl = SecureUtils.validateUrl(decryptedUrl);
600 644 if (!validatedUrl) {
601 645 console.error('CryptX: invalid or unsafe URL detected, navigation aborted');
@@ -609,8 +653,20 @@
609 653 }
610 654
611 655 /**
612 656 * Attaches the single delegated listener. Idempotent.
657 + *
658 + * The "attached" flag lives on the document itself (an expando property),
659 + * not in a closure variable. Two things went wrong with a closure variable:
660 + * first, any plugin or loader that runs cryptx.js a second time on the same
661 + * document -- @swup/scripts-plugin does, Turbo/Hotwire does, any AJAX loader
662 + * that brings footer markup along does -- gets a fresh closure and therefore
663 + * a second `click` listener on `document`; measured: one click fired
664 + * `mailto:` twice. Second, initCryptxLinkHandler(otherDocument) is exported
665 + * for exactly this use but never worked, because the closure flag was
666 + * already true from the main document and nothing was attached to the one
667 + * passed in. Marking the document, not the module, fixes both with the same
668 + * few lines.
613 669 * @param {Object} [targetDocument]
614 670 * @returns {boolean} true when the listener was attached by this call
615 671 */
616 672 function initCryptxLinkHandler(targetDocument) {
@@ -619,14 +675,14 @@
619 675 if (!doc || typeof doc.addEventListener !== 'function') {
620 676 return false;
621 677 }
622 678
623 - if (cryptxLinkHandlerAttached) {
679 + if (doc.__cryptxLinkHandlerAttached) {
624 680 return false;
625 681 }
626 682
627 683 doc.addEventListener('click', handleCryptxLinkClick, false);
628 - cryptxLinkHandlerAttached = true;
684 + doc.__cryptxLinkHandlerAttached = true;
629 685
630 686 return true;
631 687 }
632 688