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 +753 -17 1.44.2.1 View file →
@@ -1,20 +1,756 @@
1 -function DeCryptString( s )
2 -{
3 - var n = 0;
4 - var r = "mailto:";
5 - for( var i = 0; i < s.length; i++)
6 - {
7 - n = s.charCodeAt( i );
8 - if( n >= 8364 )
9 - {
10 - n = 128;
11 - }
12 - r += String.fromCharCode( n - 1 );
1 +/**
2 + * Secure CryptX Library - Fixed for backward compatibility
3 + *
4 + * Everything below lives inside an IIFE. Without it, the top-level `const` and
5 + * `class` declarations -- CONFIG, ITERATIONS, KEY_LENGTH, SecureUtils,
6 + * SecureEncryption -- sit in the global lexical environment of the page, and a
7 + * second script declaring any of those names does not merely overwrite them:
8 + * it throws "Identifier has already been declared" and one of the two scripts
9 + * stops dead. With names this general that is a matter of time, and the failure
10 + * would look like CryptX being broken for no reason.
11 + *
12 + * What the outside is meant to reach is assigned to `window` at the bottom,
13 + * deliberately and by name.
14 + */
15 +(function () {
16 +
17 +// Configuration constants
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 +);
32 +const KEY_LENGTH = window.cryptxConfig?.keyLength || 32;
33 +const IV_LENGTH = window.cryptxConfig?.ivLength || 16;
34 +const SALT_LENGTH = window.cryptxConfig?.saltLength || 16;
35 +const CONFIG = {
36 + ALLOWED_PROTOCOLS: ['http:', 'https:', 'mailto:'],
37 + MAX_URL_LENGTH: 2048,
38 + ENCRYPTION_KEY_SIZE: 32,
39 + IV_SIZE: 16
40 +};
41 +
42 +/**
43 + * Utility functions for secure operations
44 + */
45 +class SecureUtils {
46 + static getSecureRandomBytes(length) {
47 + if (typeof crypto === 'undefined' || !crypto.getRandomValues) {
48 + throw new Error('Secure random number generation not available');
49 + }
50 + return crypto.getRandomValues(new Uint8Array(length));
13 51 }
14 - return r;
52 +
53 + static arrayBufferToBase64(buffer) {
54 + const bytes = new Uint8Array(buffer);
55 + let binary = '';
56 + for (let i = 0; i < bytes.byteLength; i++) {
57 + binary += String.fromCharCode(bytes[i]);
58 + }
59 + return btoa(binary);
60 + }
61 +
62 + static base64ToArrayBuffer(base64) {
63 + const binary = atob(base64);
64 + const buffer = new ArrayBuffer(binary.length);
65 + const bytes = new Uint8Array(buffer);
66 + for (let i = 0; i < binary.length; i++) {
67 + bytes[i] = binary.charCodeAt(i);
68 + }
69 + return buffer;
70 + }
71 +
72 + static validateUrl(url) {
73 + if (typeof url !== 'string' || url.length === 0) {
74 + return null;
75 + }
76 +
77 + if (url.length > CONFIG.MAX_URL_LENGTH) {
78 + console.error('URL exceeds maximum length');
79 + return null;
80 + }
81 +
82 + try {
83 + const urlObj = new URL(url);
84 +
85 + if (!CONFIG.ALLOWED_PROTOCOLS.includes(urlObj.protocol)) {
86 + console.error('Protocol not allowed:', urlObj.protocol);
87 + return null;
88 + }
89 +
90 + if (urlObj.protocol === 'mailto:') {
91 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
92 + if (!emailRegex.test(urlObj.pathname)) {
93 + console.error('Invalid email format in mailto URL');
94 + return null;
95 + }
96 + }
97 +
98 + return url;
99 + } catch (error) {
100 + console.error('Invalid URL format:', error.message);
101 + return null;
102 + }
103 + }
104 +
105 + static escapeJavaScript(str) {
106 + if (typeof str !== 'string') {
107 + return '';
108 + }
109 + return str.replace(/\\/g, '\\\\')
110 + .replace(/'/g, "\\'")
111 + .replace(/"/g, '\\"')
112 + .replace(/\n/g, '\\n')
113 + .replace(/\r/g, '\\r')
114 + .replace(/\t/g, '\\t');
115 + }
15 116 }
16 117
17 -function DeCryptX( s )
18 -{
19 - location.href=DeCryptString( s );
20 -}
118 +/**
119 + * Legacy encryption class - Fixed to match original PHP algorithm
120 + */
121 +class LegacyEncryption {
122 + /**
123 + * Decrypts using the original CryptX algorithm (matches PHP version)
124 + * @param {string} encryptedString
125 + * @returns {string}
126 + */
127 + static originalDecrypt(encryptedString) {
128 + if (typeof encryptedString !== 'string' || encryptedString.length === 0) {
129 + throw new Error('Invalid encrypted string');
130 + }
131 +
132 + // Constants from original algorithm
133 + const UPPER_LIMIT = 8364;
134 + const DEFAULT_VALUE = 128;
135 +
136 + let charCode = 0;
137 + let decryptedString = "mailto:";
138 + let encryptionKey = 0;
139 +
140 + try {
141 + for (let i = 0; i < encryptedString.length; i += 2) {
142 + if (i + 1 >= encryptedString.length) {
143 + break;
144 + }
145 +
146 + // Get the salt (encryption key) from current position
147 + encryptionKey = parseInt(encryptedString.charAt(i), 10);
148 +
149 + // Handle invalid salt values
150 + if (isNaN(encryptionKey)) {
151 + encryptionKey = 0;
152 + }
153 +
154 + // Get the character code from next position
155 + charCode = encryptedString.charCodeAt(i + 1);
156 +
157 + // Apply the same logic as original
158 + if (charCode >= UPPER_LIMIT) {
159 + charCode = DEFAULT_VALUE;
160 + }
161 +
162 + // Decrypt by subtracting the salt
163 + const decryptedCharCode = charCode - encryptionKey;
164 +
165 + // Validate the result
166 + if (decryptedCharCode < 0 || decryptedCharCode > 1114111) {
167 + throw new Error('Invalid character code during decryption');
168 + }
169 +
170 + decryptedString += String.fromCharCode(decryptedCharCode);
171 + }
172 +
173 + return decryptedString;
174 + } catch (error) {
175 + throw new Error('Original decryption failed: ' + error.message);
176 + }
177 + }
178 +
179 + /**
180 + * Encrypts using the original CryptX algorithm (matches PHP version)
181 + * @param {string} inputString
182 + * @returns {string}
183 + */
184 + static originalEncrypt(inputString) {
185 + if (typeof inputString !== 'string' || inputString.length === 0) {
186 + throw new Error('Invalid input string');
187 + }
188 +
189 + // Remove "mailto:" prefix if present for encryption
190 + const cleanInput = inputString.replace(/^mailto:/, '');
191 + let crypt = '';
192 +
193 + // ASCII values blacklist (from PHP constant)
194 + const ASCII_VALUES_BLACKLIST = ['32', '34', '39', '60', '62', '63', '92', '94', '96', '127'];
195 +
196 + try {
197 + for (let i = 0; i < cleanInput.length; i++) {
198 + let salt, asciiValue;
199 + let attempts = 0;
200 + const maxAttempts = 20; // Prevent infinite loops
201 +
202 + do {
203 + if (attempts >= maxAttempts) {
204 + // Fallback to a safe salt if we can't find a valid one
205 + salt = 1;
206 + asciiValue = cleanInput.charCodeAt(i) + salt;
207 + break;
208 + }
209 +
210 + // Generate random number between 0 and 3 (matching PHP rand(0,3))
211 + const randomValues = SecureUtils.getSecureRandomBytes(1);
212 + salt = randomValues[0] % 4;
213 +
214 + // Get ASCII value and add salt
215 + asciiValue = cleanInput.charCodeAt(i) + salt;
216 +
217 + // Check if value exceeds limit (matching PHP logic)
218 + if (asciiValue >= 8364) {
219 + asciiValue = 128;
220 + }
221 +
222 + attempts++;
223 + } while (ASCII_VALUES_BLACKLIST.includes(asciiValue.toString()) && attempts < maxAttempts);
224 +
225 + // Append salt and character to result
226 + crypt += salt.toString() + String.fromCharCode(asciiValue);
227 + }
228 +
229 + return crypt;
230 + } catch (error) {
231 + throw new Error('Original encryption failed: ' + error.message);
232 + }
233 + }
234 +}
235 +
236 +/**
237 + * Modern encryption class using Web Crypto API - PHP Compatible
238 + */
239 +class SecureEncryption {
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;
257 + const encoder = new TextEncoder();
258 + const keyMaterial = await crypto.subtle.importKey(
259 + 'raw',
260 + encoder.encode(password),
261 + { name: 'PBKDF2' },
262 + false,
263 + ['deriveKey']
264 + );
265 +
266 + return crypto.subtle.deriveKey(
267 + {
268 + name: 'PBKDF2',
269 + salt: salt,
270 + iterations: rounds,
271 + hash: 'SHA-256'
272 + },
273 + keyMaterial,
274 + { name: 'AES-GCM', length: KEY_LENGTH * 8},
275 + false,
276 + ['encrypt', 'decrypt']
277 + );
278 + }
279 +
280 + static async encrypt(plaintext, password) {
281 + if (typeof plaintext !== 'string' || typeof password !== 'string') {
282 + throw new Error('Both plaintext and password must be strings');
283 + }
284 +
285 + const encoder = new TextEncoder();
286 + const salt = SecureUtils.getSecureRandomBytes(16);
287 + const iv = SecureUtils.getSecureRandomBytes(CONFIG.IV_SIZE);
288 +
289 + const key = await this.deriveKey(password, salt);
290 +
291 + const encrypted = await crypto.subtle.encrypt(
292 + { name: 'AES-GCM', iv: iv },
293 + key,
294 + encoder.encode(plaintext)
295 + );
296 +
297 + // Match PHP format: salt(16) + iv(16) + encrypted_data + tag(16)
298 + const encryptedArray = new Uint8Array(encrypted);
299 + const encryptedData = encryptedArray.slice(0, -16); // Remove tag from encrypted data
300 + const tag = encryptedArray.slice(-16); // Get the tag
301 +
302 + const combined = new Uint8Array(salt.length + iv.length + encryptedData.length + tag.length);
303 + combined.set(salt, 0);
304 + combined.set(iv, salt.length);
305 + combined.set(encryptedData, salt.length + iv.length);
306 + combined.set(tag, salt.length + iv.length + encryptedData.length);
307 +
308 + return SecureUtils.arrayBufferToBase64(combined.buffer);
309 + }
310 +
311 + /**
312 + * @param {string} encryptedData
313 + * @param {string} password
314 + * @param {number} [iterations] See deriveKey().
315 + */
316 + static async decrypt(encryptedData, password, iterations) {
317 + if (typeof encryptedData !== 'string' || typeof password !== 'string') {
318 + throw new Error('Both encryptedData and password must be strings');
319 + }
320 +
321 + try {
322 + const combined = SecureUtils.base64ToArrayBuffer(encryptedData);
323 + const totalLength = combined.byteLength;
324 +
325 + // PHP format: salt(16) + iv(16) + encrypted_data + tag(16)
326 + const saltLength = 16;
327 + const ivLength = 16;
328 + const tagLength = 16;
329 + const encryptedDataLength = totalLength - saltLength - ivLength - tagLength;
330 +
331 + if (totalLength < saltLength + ivLength + tagLength) {
332 + throw new Error('Encrypted data too short');
333 + }
334 +
335 + const salt = combined.slice(0, saltLength);
336 + const iv = combined.slice(saltLength, saltLength + ivLength);
337 + const encryptedDataOnly = combined.slice(saltLength + ivLength, saltLength + ivLength + encryptedDataLength);
338 + const tag = combined.slice(-tagLength); // Last 16 bytes
339 +
340 + const key = await this.deriveKey(password, new Uint8Array(salt), iterations);
341 +
342 + // Reconstruct the encrypted data with tag for Web Crypto API
343 + const encryptedWithTag = new Uint8Array(encryptedDataOnly.byteLength + tag.byteLength);
344 + encryptedWithTag.set(new Uint8Array(encryptedDataOnly), 0);
345 + encryptedWithTag.set(new Uint8Array(tag), encryptedDataOnly.byteLength);
346 +
347 + const decrypted = await crypto.subtle.decrypt(
348 + { name: 'AES-GCM', iv: new Uint8Array(iv) },
349 + key,
350 + encryptedWithTag
351 + );
352 +
353 + const decoder = new TextDecoder();
354 + return decoder.decode(decrypted);
355 + } catch (error) {
356 + throw new Error('Decryption failed: ' + error.message);
357 + }
358 + }
359 +}
360 +
361 +/**
362 + * Main CryptX functions with backward compatibility
363 + */
364 +
365 +/**
366 + * Securely decrypts and validates a URL before navigation
367 + * @param {string} encryptedUrl
368 + * @param {string} password
369 + */
370 +async function secureDecryptAndNavigate(encryptedUrl, password = 'default_key', iterations) {
371 + if (typeof encryptedUrl !== 'string' || encryptedUrl.length === 0) {
372 + console.error('Invalid encrypted URL provided');
373 + return;
374 + }
375 +
376 + try {
377 + let decryptedUrl;
378 +
379 + // Try modern decryption first, then fall back to original algorithm
380 + try {
381 + decryptedUrl = await SecureEncryption.decrypt(encryptedUrl, password, iterations);
382 + } catch (modernError) {
383 + console.warn('Modern decryption failed, trying original algorithm');
384 + decryptedUrl = LegacyEncryption.originalDecrypt(encryptedUrl);
385 + }
386 +
387 + const validatedUrl = SecureUtils.validateUrl(decryptedUrl);
388 + if (!validatedUrl) {
389 + console.error('Invalid or unsafe URL detected');
390 + return;
391 + }
392 +
393 + window.location.href = validatedUrl;
394 +
395 + } catch (error) {
396 + console.error('Error during URL decryption and navigation:', error.message);
397 + }
398 +}
399 +
400 +/**
401 + * Legacy function for backward compatibility - using original algorithm
402 + * @param {string} encryptedString
403 + * @returns {string|null}
404 + */
405 +function DeCryptString(encryptedString) {
406 + try {
407 + return LegacyEncryption.originalDecrypt(encryptedString);
408 + } catch (error) {
409 + console.error('Legacy decryption failed:', error.message);
410 + return null;
411 + }
412 +}
413 +
414 +/**
415 + * Legacy function for backward compatibility - secured
416 + * @param {string} encryptedUrl
417 + */
418 +function DeCryptX(encryptedUrl) {
419 + const decryptedUrl = DeCryptString(encryptedUrl);
420 + if (!decryptedUrl) {
421 + console.error('Failed to decrypt URL');
422 + return;
423 + }
424 +
425 + const validatedUrl = SecureUtils.validateUrl(decryptedUrl);
426 + if (!validatedUrl) {
427 + console.error('Invalid or unsafe URL detected');
428 + return;
429 + }
430 +
431 + window.location.href = validatedUrl;
432 +}
433 +
434 +/**
435 + * Generates encrypted email link with proper security
436 + * @param {string} emailAddress
437 + * @param {string} password
438 + * @returns {Promise<string>}
439 + */
440 +async function generateSecureEmailLink(emailAddress, password = 'default_key') {
441 + if (typeof emailAddress !== 'string' || emailAddress.length === 0) {
442 + throw new Error('Valid email address required');
443 + }
444 +
445 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
446 + if (!emailRegex.test(emailAddress)) {
447 + throw new Error('Invalid email format');
448 + }
449 +
450 + const mailtoUrl = `mailto:${emailAddress}`;
451 + const encryptedData = await SecureEncryption.encrypt(mailtoUrl, password);
452 + const escapedData = SecureUtils.escapeJavaScript(encryptedData);
453 +
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})`;
461 +}
462 +
463 +/**
464 + * Legacy function for backward compatibility - using original algorithm
465 + * @param {string} emailAddress
466 + * @returns {string}
467 + */
468 +function generateDeCryptXHandler(emailAddress) {
469 + if (typeof emailAddress !== 'string' || emailAddress.length === 0) {
470 + console.error('Valid email address required');
471 + return 'javascript:void(0)';
472 + }
473 +
474 + try {
475 + const encrypted = LegacyEncryption.originalEncrypt(emailAddress);
476 + const escaped = SecureUtils.escapeJavaScript(encrypted);
477 + return `javascript:DeCryptX('${escaped}')`;
478 + } catch (error) {
479 + console.error('Error generating handler:', error.message);
480 + return 'javascript:void(0)';
481 + }
482 +}
483 +
484 +/**
485 + * Legacy function - matches original PHP generateHashFromString
486 + * @param {string} inputString
487 + * @returns {string}
488 + */
489 +function generateHashFromString(inputString) {
490 + try {
491 + return LegacyEncryption.originalEncrypt(inputString);
492 + } catch (error) {
493 + console.error('Error generating hash:', error.message);
494 + return '';
495 + }
496 +}
497 +
498 +/**
499 + * CSP-safe link handling
500 + *
501 + * Markup produced by the PHP side (no javascript: URI, therefore no
502 + * 'unsafe-inline' needed in the Content-Security-Policy):
503 + *
504 + * <a href="#" class="cryptx-link" data-cx="BASE64" data-cxk="PASSWORD" data-cxm="secure" data-cxi="10000">…</a>
505 + * <a href="#" class="cryptx-link" data-cx="0i2p2h…" data-cxm="legacy">…</a>
506 + *
507 + * A single delegated listener on `document` covers links that are added later
508 + * (widgets, AJAX, block editor preview). The legacy javascript: entry points
509 + * above stay untouched for pages that were cached before this version.
510 + */
511 +
512 +const CRYPTX_LINK_CLASS = 'cryptx-link';
513 +const CRYPTX_ATTR_PAYLOAD = 'data-cx';
514 +const CRYPTX_ATTR_KEY = 'data-cxk';
515 +const CRYPTX_ATTR_MODE = 'data-cxm';
516 +const CRYPTX_ATTR_ITERATIONS = 'data-cxi';
517 +const CRYPTX_MAX_DELEGATION_DEPTH = 50;
518 +
519 +/**
520 + * True only when the Web Crypto API is usable (secure context, modern browser)
521 + * @returns {boolean}
522 + */
523 +function cryptxHasSubtleCrypto() {
524 + return typeof crypto !== 'undefined' &&
525 + !!crypto &&
526 + !!crypto.subtle &&
527 + typeof crypto.subtle.importKey === 'function';
528 +}
529 +
530 +/**
531 + * @param {*} element
532 + * @returns {boolean}
533 + */
534 +function isCryptxLink(element) {
535 + if (!element || typeof element.getAttribute !== 'function') {
536 + return false;
537 + }
538 +
539 + if (element.classList && typeof element.classList.contains === 'function') {
540 + return element.classList.contains(CRYPTX_LINK_CLASS);
541 + }
542 +
543 + if (typeof element.className === 'string') {
544 + return (' ' + element.className + ' ').indexOf(' ' + CRYPTX_LINK_CLASS + ' ') !== -1;
545 + }
546 +
547 + return false;
548 +}
549 +
550 +/**
551 + * Walks up from the event target to the CryptX link (clicks may land on a
552 + * child element, e.g. an <img> or <span> inside the anchor).
553 + * @param {*} startNode
554 + * @returns {*|null}
555 + */
556 +function findCryptxLink(startNode) {
557 + let node = startNode;
558 + let depth = 0;
559 +
560 + while (node && depth < CRYPTX_MAX_DELEGATION_DEPTH) {
561 + if (isCryptxLink(node)) {
562 + return node;
563 + }
564 + node = node.parentElement || node.parentNode || null;
565 + depth++;
566 + }
567 +
568 + return null;
569 +}
570 +
571 +/**
572 + * Decrypts a data-cx payload according to data-cxm.
573 + * Missing mode behaves like "secure" with a fallback to "legacy",
574 + * exactly like secureDecryptAndNavigate() does.
575 + * @param {string} payload
576 + * @param {string|null} password
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.
581 + * @returns {Promise<string>}
582 + */
583 +async function cryptxDecryptPayload(payload, password, mode, iterations) {
584 + if (typeof payload !== 'string' || payload.length === 0) {
585 + throw new Error('Missing or invalid data-cx payload');
586 + }
587 +
588 + const normalizedMode = typeof mode === 'string' ? mode.trim().toLowerCase() : '';
589 +
590 + if (normalizedMode === 'legacy') {
591 + return LegacyEncryption.originalDecrypt(payload);
592 + }
593 +
594 + if (!cryptxHasSubtleCrypto()) {
595 + if (normalizedMode === 'secure') {
596 + throw new Error('Web Crypto API (crypto.subtle) is not available in this context');
597 + }
598 + return LegacyEncryption.originalDecrypt(payload);
599 + }
600 +
601 + const key = typeof password === 'string' && password.length > 0 ? password : 'default_key';
602 + const rounds = parseInt(iterations, 10);
603 +
604 + try {
605 + return await SecureEncryption.decrypt(payload, key, rounds);
606 + } catch (secureError) {
607 + if (normalizedMode === 'secure') {
608 + throw secureError;
609 + }
610 + console.warn('CryptX: modern decryption failed, trying original algorithm');
611 + return LegacyEncryption.originalDecrypt(payload);
612 + }
613 +}
614 +
615 +/**
616 + * Delegated click handler. Never navigates without SecureUtils.validateUrl().
617 + * @param {Object} event
618 + * @returns {Promise<void>}
619 + */
620 +async function handleCryptxLinkClick(event) {
621 + if (!event) {
622 + return;
623 + }
624 +
625 + const link = findCryptxLink(event.target);
626 + if (!link) {
627 + return;
628 + }
629 +
630 + // Suppress the "#" jump before anything asynchronous happens.
631 + if (typeof event.preventDefault === 'function') {
632 + event.preventDefault();
633 + }
634 +
635 + const payload = link.getAttribute(CRYPTX_ATTR_PAYLOAD);
636 + const password = link.getAttribute(CRYPTX_ATTR_KEY);
637 + const mode = link.getAttribute(CRYPTX_ATTR_MODE);
638 + const iterations = link.getAttribute(CRYPTX_ATTR_ITERATIONS);
639 +
640 + try {
641 + const decryptedUrl = await cryptxDecryptPayload(payload, password, mode, iterations);
642 +
643 + const validatedUrl = SecureUtils.validateUrl(decryptedUrl);
644 + if (!validatedUrl) {
645 + console.error('CryptX: invalid or unsafe URL detected, navigation aborted');
646 + return;
647 + }
648 +
649 + window.location.href = validatedUrl;
650 + } catch (error) {
651 + console.error('CryptX: could not resolve link target:', error && error.message ? error.message : error);
652 + }
653 +}
654 +
655 +/**
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.
669 + * @param {Object} [targetDocument]
670 + * @returns {boolean} true when the listener was attached by this call
671 + */
672 +function initCryptxLinkHandler(targetDocument) {
673 + const doc = targetDocument || (typeof document !== 'undefined' ? document : null);
674 +
675 + if (!doc || typeof doc.addEventListener !== 'function') {
676 + return false;
677 + }
678 +
679 + if (doc.__cryptxLinkHandlerAttached) {
680 + return false;
681 + }
682 +
683 + doc.addEventListener('click', handleCryptxLinkClick, false);
684 + doc.__cryptxLinkHandlerAttached = true;
685 +
686 + return true;
687 +}
688 +
689 +// Attach immediately - delegation on `document` needs no finished DOM, so this
690 +// works for a <head> include as well as for a footer include, where
691 +// DOMContentLoaded may already have fired and would never come again.
692 +if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
693 + initCryptxLinkHandler(document);
694 +
695 + if (document.readyState === 'loading') {
696 + // Safety net for exotic environments that replace `document` while parsing.
697 + document.addEventListener('DOMContentLoaded', function () {
698 + initCryptxLinkHandler(document);
699 + });
700 + }
701 +}
702 +
703 +// Keep everything reachable by name, also after minification.
704 +//
705 +// The three the plugin itself depends on are secureDecryptAndNavigate and
706 +// DeCryptX, which appear in the generated "javascript:" links, and
707 +// generateDeCryptXHandler, which the help tab documents for use in a theme.
708 +// The rest is kept because it has been exported for years; new code should use
709 +// the window.CryptX namespace, and the bare names will go with the next major
710 +// release, together with the deprecated encryptx().
711 +if (typeof window !== 'undefined') {
712 + window.CryptX = {
713 + secureDecryptAndNavigate,
714 + DeCryptX,
715 + DeCryptString,
716 + generateSecureEmailLink,
717 + generateDeCryptXHandler,
718 + generateHashFromString,
719 + handleCryptxLinkClick,
720 + initCryptxLinkHandler,
721 + SecureUtils,
722 + LegacyEncryption,
723 + SecureEncryption
724 + };
725 +
726 + window.secureDecryptAndNavigate = secureDecryptAndNavigate;
727 + window.DeCryptX = DeCryptX;
728 + window.DeCryptString = DeCryptString;
729 + window.generateSecureEmailLink = generateSecureEmailLink;
730 + window.generateDeCryptXHandler = generateDeCryptXHandler;
731 + window.generateHashFromString = generateHashFromString;
732 + window.handleCryptxLinkClick = handleCryptxLinkClick;
733 + window.initCryptxLinkHandler = initCryptxLinkHandler;
734 + window.SecureUtils = SecureUtils;
735 + window.LegacyEncryption = LegacyEncryption;
736 + window.SecureEncryption = SecureEncryption;
737 +}
738 +
739 +// Export functions for module usage
740 +if (typeof module !== 'undefined' && module.exports) {
741 + module.exports = {
742 + secureDecryptAndNavigate,
743 + generateSecureEmailLink,
744 + DeCryptX,
745 + DeCryptString,
746 + generateDeCryptXHandler,
747 + generateHashFromString,
748 + handleCryptxLinkClick,
749 + initCryptxLinkHandler,
750 + cryptxDecryptPayload,
751 + SecureEncryption,
752 + LegacyEncryption,
753 + SecureUtils
754 + };
755 +}
756 +})();