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