PluginProbe
CryptX / trunk
CryptX vtrunk
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.6trunk 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 +})();