diff --git a/export/index.template.html b/export/index.template.html index e9d2324..bacad1a 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -75,6 +75,96 @@ .hidden { display: none; } + #passphrase-form-div { + text-align: center; + max-width: 500px; + margin: 2em auto; + padding: 1.5em; + border: 1px solid #ddd; + border-radius: 8px; + background-color: #fafafa; + } + #passphrase-form-div h2 { + margin-top: 0; + color: #333; + } + #passphrase-form-div p { + color: #555; + font-size: 0.9em; + margin-bottom: 1.5em; + } + #passphrase-form-div label { + display: block; + text-align: left; + margin: 0.5em 0 0.25em 0; + font-weight: bold; + color: #444; + } + #passphrase-form-div input[type="password"] { + width: 100%; + padding: 0.6em; + font-size: 1em; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + margin-bottom: 0.5em; + } + #passphrase-form-div input[type="password"]:focus { + outline: none; + border-color: #666; + } + #encrypt-and-export { + color: white; + width: 100%; + font-size: 1em; + padding: 0.75em; + margin-top: 1em; + border-radius: 4px; + background-color: rgb(50, 44, 44); + border: 1px rgb(33, 33, 33) solid; + cursor: pointer; + } + #encrypt-and-export:hover { + background-color: rgb(70, 64, 64); + } + #passphrase-error { + color: #c0392b; + font-size: 0.9em; + margin: 0.5em 0; + text-align: left; + } + #passphrase-strength { + margin-top: 0.5em; + text-align: left; + } + #passphrase-strength-bar { + height: 6px; + border-radius: 3px; + background-color: #e0e0e0; + overflow: hidden; + margin-bottom: 0.3em; + } + #passphrase-strength-fill { + height: 100%; + width: 0%; + transition: width 0.3s ease, background-color 0.3s ease; + } + #passphrase-strength-text { + font-size: 0.8em; + color: #666; + } + .strength-weak { + background-color: #e74c3c; + } + .strength-fair { + background-color: #f39c12; + } + .strength-good { + background-color: #3498db; + } + .strength-strong { + background-color: #27ae60; + } @@ -1191,6 +1281,104 @@

Message log

return JSON.stringify(validSettings); } + /** + * Encrypts a Uint8Array using PBKDF2 key derivation and AES-GCM-256 encryption. + * @param {Uint8Array} buf - The data to encrypt + * @param {string} passphrase - The passphrase to derive the key from + * @returns {Promise} - Concatenated salt || iv || ciphertext + */ + async function encryptWithPassphrase(buf, passphrase) { + // Generate random 16-byte salt and 12-byte IV + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Import passphrase as PBKDF2 key material + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(passphrase), + "PBKDF2", + false, + ["deriveBits", "deriveKey"] + ); + + // Derive AES-256 key using PBKDF2 (600,000 iterations, SHA-256). + // NOTE: The iteration count must match during decryption; changing it + // affects compatibility with data encrypted using a different value. + const aesKey = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 600000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt"] + ); + + // Encrypt using AES-GCM + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + aesKey, + buf + ); + + // Return concatenated salt || iv || ciphertext + const result = new Uint8Array( + salt.length + iv.length + ciphertext.byteLength + ); + result.set(salt, 0); + result.set(iv, salt.length); + result.set(new Uint8Array(ciphertext), salt.length + iv.length); + return result; + } + + /** + * Decrypts a buffer encrypted by encryptWithPassphrase. + * @param {Uint8Array} encryptedBuf - The encrypted data (salt || iv || ciphertext) + * @param {string} passphrase - The passphrase to derive the key from + * @returns {Promise} - The decrypted data + */ + async function decryptWithPassphrase(encryptedBuf, passphrase) { + // Extract salt (bytes 0-16), iv (bytes 16-28), ciphertext (bytes 28+) + const salt = encryptedBuf.slice(0, 16); + const iv = encryptedBuf.slice(16, 28); + const ciphertext = encryptedBuf.slice(28); + + // Import passphrase as PBKDF2 key material + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(passphrase), + "PBKDF2", + false, + ["deriveBits", "deriveKey"] + ); + + // Derive same AES key using PBKDF2 + const aesKey = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 600000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"] + ); + + // Decrypt using AES-GCM + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: iv }, + aesKey, + ciphertext + ); + + return new Uint8Array(decrypted); + } + return { initEmbeddedKey, generateTargetKey, @@ -1221,6 +1409,8 @@

Message log

validateStyles, getSettings, setSettings, + encryptWithPassphrase, + decryptWithPassphrase, }; })(); @@ -1312,6 +1502,23 @@

Message log

TKHQ.sendMessageUp("ERROR", e.toString(), event.data["requestId"]); } } + if ( + event.data && + event.data["type"] == "INJECT_WALLET_EXPORT_BUNDLE_ENCRYPTED" + ) { + TKHQ.logMessage( + `⬇️ Received message ${event.data["type"]}: ${event.data["value"]}, ${event.data["organizationId"]}` + ); + try { + await onInjectWalletBundleEncrypted( + event.data["value"], + event.data["organizationId"], + event.data["requestId"] + ); + } catch (e) { + TKHQ.sendMessageUp("ERROR", e.toString(), event.data["requestId"]); + } + } if (event.data && event.data["type"] == "APPLY_SETTINGS") { try { await onApplySettings(event.data["value"], event.data["requestId"]); @@ -1432,6 +1639,277 @@

Message log

TKHQ.applySettings(TKHQ.getSettings()); } + /** + * Display a passphrase form to encrypt the mnemonic before exporting. + * @param {string} mnemonic - The wallet mnemonic to encrypt + * @param {string} requestId - The request ID for message correlation + */ + function displayPassphraseForm(mnemonic, requestId) { + // Hide all existing DOM elements except scripts + Array.from(document.body.children).forEach((child) => { + if (child.tagName !== "SCRIPT") { + child.style.display = "none"; + } + }); + + // Create the passphrase form container + const formDiv = document.createElement("form"); + formDiv.id = "passphrase-form-div"; + + // Create heading + const heading = document.createElement("h2"); + heading.innerText = "Encrypt Your Wallet Export"; + formDiv.appendChild(heading); + + // Create description + const description = document.createElement("p"); + description.innerText = + "Enter a passphrase to encrypt your wallet mnemonic. You will need this passphrase to decrypt your wallet later."; + formDiv.appendChild(description); + + // Create passphrase input + const passphraseLabel = document.createElement("label"); + passphraseLabel.setAttribute("for", "export-passphrase"); + passphraseLabel.innerText = "Passphrase"; + formDiv.appendChild(passphraseLabel); + + const passphraseInput = document.createElement("input"); + passphraseInput.type = "password"; + passphraseInput.id = "export-passphrase"; + passphraseInput.placeholder = "Enter passphrase (min 8 characters)"; + passphraseInput.required = true; + passphraseInput.setAttribute("aria-required", "true"); + passphraseInput.setAttribute("autocomplete", "new-password"); + passphraseInput.minLength = 8; + formDiv.appendChild(passphraseInput); + + // Create passphrase strength indicator + const strengthDiv = document.createElement("div"); + strengthDiv.id = "passphrase-strength"; + + const strengthBar = document.createElement("div"); + strengthBar.id = "passphrase-strength-bar"; + + const strengthFill = document.createElement("div"); + strengthFill.id = "passphrase-strength-fill"; + strengthBar.appendChild(strengthFill); + strengthDiv.appendChild(strengthBar); + + const strengthText = document.createElement("span"); + strengthText.id = "passphrase-strength-text"; + strengthDiv.appendChild(strengthText); + formDiv.appendChild(strengthDiv); + + /** + * Evaluate passphrase strength and return score (0-4) with feedback + * @param {string} passphrase + * @returns {{score: number, label: string, feedback: string}} + */ + function evaluatePassphraseStrength(passphrase) { + if (!passphrase) { + return { score: 0, label: "", feedback: "" }; + } + + let score = 0; + const checks = { + length8: passphrase.length >= 8, + length12: passphrase.length >= 12, + length16: passphrase.length >= 16, + lowercase: /[a-z]/.test(passphrase), + uppercase: /[A-Z]/.test(passphrase), + numbers: /[0-9]/.test(passphrase), + special: /[^a-zA-Z0-9]/.test(passphrase), + }; + + // Base score from length + if (checks.length8) score += 1; + if (checks.length12) score += 1; + if (checks.length16) score += 1; + + // Character variety + const varietyCount = [ + checks.lowercase, + checks.uppercase, + checks.numbers, + checks.special, + ].filter(Boolean).length; + if (varietyCount >= 2) score += 1; + if (varietyCount >= 3) score += 1; + if (varietyCount >= 4) score += 1; + + // Cap at 4 + score = Math.min(score, 4); + + // Determine label and feedback + let label, feedback; + if (score <= 1) { + label = "Weak"; + feedback = + "Add more characters and mix letters, numbers, and symbols."; + } else if (score === 2) { + label = "Fair"; + feedback = "Consider adding more length or character variety."; + } else if (score === 3) { + label = "Good"; + feedback = "Good passphrase strength."; + } else { + label = "Strong"; + feedback = "Excellent passphrase strength!"; + } + + return { score, label, feedback }; + } + + // Update strength indicator on input + passphraseInput.addEventListener("input", () => { + const { score, label, feedback } = evaluatePassphraseStrength( + passphraseInput.value + ); + + // Update fill width and color + const strengthClasses = [ + "strength-weak", + "strength-fair", + "strength-good", + "strength-strong", + ]; + strengthFill.className = ""; + + if (score === 0) { + strengthFill.style.width = "0%"; + strengthText.textContent = ""; + } else { + strengthFill.style.width = `${score * 25}%`; + strengthFill.classList.add(strengthClasses[Math.min(score - 1, 3)]); + strengthText.textContent = `${label} — ${feedback}`; + } + }); + + // Create confirmation input + const confirmLabel = document.createElement("label"); + confirmLabel.setAttribute("for", "export-passphrase-confirm"); + confirmLabel.innerText = "Confirm Passphrase"; + formDiv.appendChild(confirmLabel); + + const confirmInput = document.createElement("input"); + confirmInput.type = "password"; + confirmInput.id = "export-passphrase-confirm"; + confirmInput.placeholder = "Confirm passphrase"; + confirmInput.required = true; + confirmInput.setAttribute("aria-required", "true"); + confirmInput.setAttribute("autocomplete", "new-password"); + formDiv.appendChild(confirmInput); + + // Create error message paragraph + const errorMsg = document.createElement("p"); + errorMsg.id = "passphrase-error"; + errorMsg.style.display = "none"; + formDiv.appendChild(errorMsg); + + // Create submit button + const submitButton = document.createElement("button"); + submitButton.type = "submit"; + submitButton.id = "encrypt-and-export"; + submitButton.innerText = "Encrypt & Export"; + formDiv.appendChild(submitButton); + + // Append the form to the body + document.body.appendChild(formDiv); + + // Add submit event listener to the form + formDiv.addEventListener("submit", async (event) => { + event.preventDefault(); + const passphrase = passphraseInput.value; + const confirmPassphrase = confirmInput.value; + + // Validate minimum passphrase length (8 characters) + if (passphrase.length < 8) { + errorMsg.innerText = + "Passphrase must be at least 8 characters long."; + errorMsg.style.display = "block"; + return; + } + + // Validate passphrases match + if (passphrase !== confirmPassphrase) { + errorMsg.innerText = "Passphrases do not match."; + errorMsg.style.display = "block"; + return; + } + + // Hide error message and disable button to prevent duplicate submissions + errorMsg.style.display = "none"; + submitButton.disabled = true; + + let mnemonicBytes; + let encryptedBytes; + try { + // Encode mnemonic to Uint8Array + const encoder = new TextEncoder(); + mnemonicBytes = encoder.encode(mnemonic); + + // Encrypt with passphrase + encryptedBytes = await TKHQ.encryptWithPassphrase( + mnemonicBytes, + passphrase + ); + + // Convert to base64 + const encryptedBase64 = btoa( + Array.from(encryptedBytes, (b) => String.fromCharCode(b)).join("") + ); + + // Clear passphrase fields for security before sending message + passphraseInput.value = ""; + confirmInput.value = ""; + // Reset strength indicator + strengthFill.style.width = "0%"; + strengthFill.className = ""; + strengthText.textContent = ""; + + // Zero out sensitive byte arrays (mutable, so we can wipe them) + for (let i = 0; i < mnemonicBytes.length; i++) mnemonicBytes[i] = 0; + for (let i = 0; i < encryptedBytes.length; i++) + encryptedBytes[i] = 0; + + // Clear the mnemonic closure reference (can't truly zero a JS string, but removes the easy reference) + mnemonic = ""; + + // Send message up + TKHQ.sendMessageUp( + "ENCRYPTED_WALLET_EXPORT", + encryptedBase64, + requestId + ); + + // Remove the entire form from the DOM — no sensitive material should linger + formDiv.remove(); + } catch (e) { + // Zero out sensitive byte arrays if they were created + if (mnemonicBytes) { + for (let i = 0; i < mnemonicBytes.length; i++) + mnemonicBytes[i] = 0; + } + if (encryptedBytes) { + for (let i = 0; i < encryptedBytes.length; i++) + encryptedBytes[i] = 0; + } + + // Clear passphrase fields for security + passphraseInput.value = ""; + confirmInput.value = ""; + // Reset strength indicator + strengthFill.style.width = "0%"; + strengthFill.className = ""; + strengthText.textContent = ""; + + errorMsg.innerText = "Encryption failed: " + e.toString(); + errorMsg.style.display = "block"; + submitButton.disabled = false; + } + }); + } + /** * Parse and decrypt the export bundle. * The `bundle` param is a JSON string of the encapsulated public @@ -1585,6 +2063,30 @@

Message log

TKHQ.sendMessageUp("BUNDLE_INJECTED", true, requestId); } + /** + * Function triggered when INJECT_WALLET_EXPORT_BUNDLE_ENCRYPTED event is received. + * @param {string} bundle + * @param {string} organizationId + * @param {string} requestId + */ + async function onInjectWalletBundleEncrypted( + bundle, + organizationId, + requestId + ) { + // Decrypt the export bundle + const walletBytes = await decryptBundle(bundle, organizationId); + + // Reset embedded key after using for decryption + TKHQ.onResetEmbeddedKey(); + + // Parse the decrypted wallet bytes + const wallet = TKHQ.encodeWallet(new Uint8Array(walletBytes)); + + // Display passphrase form instead of showing the key directly + displayPassphraseForm(wallet.mnemonic, requestId); + } + /** * Function triggered when APPLY_SETTINGS event is received. * For now, the only settings that can be applied are for "styles". diff --git a/export/index.test.js b/export/index.test.js index 79648fe..dd13277 100644 --- a/export/index.test.js +++ b/export/index.test.js @@ -446,4 +446,392 @@ describe("TKHQ", () => { }; expect(TKHQ.validateStyles(allStylesValid)).toEqual(allStylesValid); }); + + it("encrypts data with passphrase correctly", async () => { + const plaintext = new TextEncoder().encode("test mnemonic phrase"); + const passphrase = "securepassword123"; + + const encrypted = await TKHQ.encryptWithPassphrase(plaintext, passphrase); + + // Result should be salt (16) + iv (12) + ciphertext (at least as long as plaintext + auth tag) + // Note: Using ArrayBuffer check due to JSDOM cross-realm Uint8Array difference + expect(encrypted.buffer).toBeDefined(); + expect(encrypted.length).toBeGreaterThanOrEqual(16 + 12 + plaintext.length); + + // Salt and IV should be present at the beginning + const salt = encrypted.slice(0, 16); + const iv = encrypted.slice(16, 28); + expect(salt.length).toBe(16); + expect(iv.length).toBe(12); + }); + + it("decrypts data encrypted by encryptWithPassphrase correctly", async () => { + const originalText = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const plaintext = new TextEncoder().encode(originalText); + const passphrase = "mySecurePassphrase!"; + + // Encrypt + const encrypted = await TKHQ.encryptWithPassphrase(plaintext, passphrase); + + // Decrypt + const decrypted = await TKHQ.decryptWithPassphrase(encrypted, passphrase); + + // Verify + const decryptedText = new TextDecoder().decode(decrypted); + expect(decryptedText).toBe(originalText); + }); + + it("fails to decrypt with wrong passphrase", async () => { + const plaintext = new TextEncoder().encode("secret data"); + const correctPassphrase = "correctPassphrase"; + const wrongPassphrase = "wrongPassphrase"; + + const encrypted = await TKHQ.encryptWithPassphrase( + plaintext, + correctPassphrase + ); + + // Attempting to decrypt with wrong passphrase should throw + await expect( + TKHQ.decryptWithPassphrase(encrypted, wrongPassphrase) + ).rejects.toThrow(); + }); + + it("produces different ciphertext for same plaintext (due to random salt/IV)", async () => { + const plaintext = new TextEncoder().encode("same plaintext"); + const passphrase = "samePassphrase"; + + const encrypted1 = await TKHQ.encryptWithPassphrase(plaintext, passphrase); + const encrypted2 = await TKHQ.encryptWithPassphrase(plaintext, passphrase); + + // Encrypted results should be different due to random salt and IV + expect(TKHQ.uint8arrayToHexString(encrypted1)).not.toBe( + TKHQ.uint8arrayToHexString(encrypted2) + ); + + // But both should decrypt to the same plaintext + const decrypted1 = await TKHQ.decryptWithPassphrase(encrypted1, passphrase); + const decrypted2 = await TKHQ.decryptWithPassphrase(encrypted2, passphrase); + + expect(new TextDecoder().decode(decrypted1)).toBe("same plaintext"); + expect(new TextDecoder().decode(decrypted2)).toBe("same plaintext"); + }); + + it("handles encryption of wallet mnemonic end-to-end", async () => { + const mnemonic = + "suffer surround soup duck goose patrol add unveil appear eye neglect hurry alpha project tomorrow embody hen wish twenty join notable amused burden treat"; + const passphrase = "strongPassphrase123!"; + + // Encode mnemonic to bytes + const mnemonicBytes = new TextEncoder().encode(mnemonic); + + // Encrypt + const encrypted = await TKHQ.encryptWithPassphrase( + mnemonicBytes, + passphrase + ); + + // Convert to base64 (as would be done in displayPassphraseForm) + const encryptedBase64 = btoa( + Array.from(encrypted, (b) => String.fromCharCode(b)).join("") + ); + expect(typeof encryptedBase64).toBe("string"); + expect(encryptedBase64.length).toBeGreaterThan(0); + + // Convert back from base64 + const encryptedFromBase64 = new Uint8Array( + atob(encryptedBase64) + .split("") + .map((c) => c.charCodeAt(0)) + ); + + // Decrypt + const decrypted = await TKHQ.decryptWithPassphrase( + encryptedFromBase64, + passphrase + ); + const decryptedMnemonic = new TextDecoder().decode(decrypted); + + expect(decryptedMnemonic).toBe(mnemonic); + }); +}); + +/** + * Tests for passphrase form validation + * These tests create the form elements manually and test the validation logic + */ +describe("Passphrase Form Validation", () => { + let dom; + let document; + let TKHQ; + + // Helper to create the passphrase form elements (mimics displayPassphraseForm) + function createPassphraseForm() { + const formDiv = document.createElement("form"); + formDiv.id = "passphrase-form-div"; + + const passphraseInput = document.createElement("input"); + passphraseInput.type = "password"; + passphraseInput.id = "export-passphrase"; + formDiv.appendChild(passphraseInput); + + const confirmInput = document.createElement("input"); + confirmInput.type = "password"; + confirmInput.id = "export-passphrase-confirm"; + formDiv.appendChild(confirmInput); + + const errorMsg = document.createElement("p"); + errorMsg.id = "passphrase-error"; + errorMsg.style.display = "none"; + formDiv.appendChild(errorMsg); + + const submitButton = document.createElement("button"); + submitButton.type = "submit"; + submitButton.id = "encrypt-and-export"; + formDiv.appendChild(submitButton); + + document.body.appendChild(formDiv); + + return { formDiv, passphraseInput, confirmInput, errorMsg, submitButton }; + } + + // Helper to submit form (triggers validation) + function submitForm(elements) { + const event = new dom.window.Event("submit", { + bubbles: true, + cancelable: true, + }); + elements.formDiv.dispatchEvent(event); + } + + // Helper to create submit handler that mimics displayPassphraseForm logic + function addValidationHandler(elements, mnemonic, onSuccess) { + const { formDiv, passphraseInput, confirmInput, errorMsg } = elements; + + formDiv.addEventListener("submit", async (event) => { + event.preventDefault(); + const passphrase = passphraseInput.value; + const confirmPassphrase = confirmInput.value; + + // Validate minimum passphrase length (8 characters) + if (passphrase.length < 8) { + errorMsg.innerText = "Passphrase must be at least 8 characters long."; + errorMsg.style.display = "block"; + return; + } + + // Validate passphrases match + if (passphrase !== confirmPassphrase) { + errorMsg.innerText = "Passphrases do not match."; + errorMsg.style.display = "block"; + return; + } + + // Hide error message + errorMsg.style.display = "none"; + + if (onSuccess) { + await onSuccess(passphrase); + } + }); + } + + beforeEach(() => { + dom = new JSDOM(html, { + runScripts: "dangerously", + url: "http://localhost", + beforeParse(window) { + window.TextDecoder = TextDecoder; + window.TextEncoder = TextEncoder; + }, + }); + + Object.defineProperty(dom.window, "crypto", { + value: crypto.webcrypto, + }); + + document = dom.window.document; + TKHQ = dom.window.TKHQ; + }); + + it("shows error when passphrase is too short", () => { + const elements = createPassphraseForm(); + addValidationHandler(elements, "test mnemonic"); + + // Set a passphrase that's too short (< 8 chars) + elements.passphraseInput.value = "short"; + elements.confirmInput.value = "short"; + + // Submit form + submitForm(elements); + + // Error should be displayed + expect(elements.errorMsg.style.display).toBe("block"); + expect(elements.errorMsg.innerText).toBe( + "Passphrase must be at least 8 characters long." + ); + }); + + it("shows error when passphrase is exactly 7 characters", () => { + const elements = createPassphraseForm(); + addValidationHandler(elements, "test mnemonic"); + + // Set a passphrase that's exactly 7 chars (boundary case) + elements.passphraseInput.value = "1234567"; + elements.confirmInput.value = "1234567"; + + submitForm(elements); + + expect(elements.errorMsg.style.display).toBe("block"); + expect(elements.errorMsg.innerText).toBe( + "Passphrase must be at least 8 characters long." + ); + }); + + it("accepts passphrase with exactly 8 characters", async () => { + const elements = createPassphraseForm(); + let successCalled = false; + + addValidationHandler(elements, "test mnemonic", async () => { + successCalled = true; + }); + + // Set a passphrase that's exactly 8 chars (boundary case - should pass) + elements.passphraseInput.value = "12345678"; + elements.confirmInput.value = "12345678"; + + submitForm(elements); + + // Allow async handler to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(elements.errorMsg.style.display).toBe("none"); + expect(successCalled).toBe(true); + }); + + it("shows error when passphrases do not match", () => { + const elements = createPassphraseForm(); + addValidationHandler(elements, "test mnemonic"); + + // Set mismatched passphrases (both >= 8 chars) + elements.passphraseInput.value = "password123"; + elements.confirmInput.value = "password456"; + + submitForm(elements); + + expect(elements.errorMsg.style.display).toBe("block"); + expect(elements.errorMsg.innerText).toBe("Passphrases do not match."); + }); + + it("shows length error before mismatch error", () => { + const elements = createPassphraseForm(); + addValidationHandler(elements, "test mnemonic"); + + // Set short AND mismatched passphrases + elements.passphraseInput.value = "short"; + elements.confirmInput.value = "diff"; + + submitForm(elements); + + // Length error should take precedence + expect(elements.errorMsg.style.display).toBe("block"); + expect(elements.errorMsg.innerText).toBe( + "Passphrase must be at least 8 characters long." + ); + }); + + it("hides error message on successful validation", async () => { + const elements = createPassphraseForm(); + addValidationHandler(elements, "test mnemonic", async () => {}); + + // First trigger an error + elements.passphraseInput.value = "short"; + elements.confirmInput.value = "short"; + submitForm(elements); + expect(elements.errorMsg.style.display).toBe("block"); + + // Now enter valid passphrases + elements.passphraseInput.value = "validpassword123"; + elements.confirmInput.value = "validpassword123"; + submitForm(elements); + + // Allow async handler to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Error should be hidden + expect(elements.errorMsg.style.display).toBe("none"); + }); + + it("accepts empty confirmation when passphrase is too short (length check first)", () => { + const elements = createPassphraseForm(); + addValidationHandler(elements, "test mnemonic"); + + // Short passphrase with empty confirmation + elements.passphraseInput.value = "short"; + elements.confirmInput.value = ""; + + submitForm(elements); + + // Should show length error, not mismatch + expect(elements.errorMsg.innerText).toBe( + "Passphrase must be at least 8 characters long." + ); + }); + + it("validates with special characters in passphrase", async () => { + const elements = createPassphraseForm(); + let receivedPassphrase = null; + + addValidationHandler(elements, "test mnemonic", async (passphrase) => { + receivedPassphrase = passphrase; + }); + + // Passphrase with special characters + const specialPass = "p@$$w0rd!#%^&*()"; + elements.passphraseInput.value = specialPass; + elements.confirmInput.value = specialPass; + + submitForm(elements); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(elements.errorMsg.style.display).toBe("none"); + expect(receivedPassphrase).toBe(specialPass); + }); + + it("validates with unicode characters in passphrase", async () => { + const elements = createPassphraseForm(); + let receivedPassphrase = null; + + addValidationHandler(elements, "test mnemonic", async (passphrase) => { + receivedPassphrase = passphrase; + }); + + // Passphrase with unicode + const unicodePass = "密码🔐secure"; + elements.passphraseInput.value = unicodePass; + elements.confirmInput.value = unicodePass; + + submitForm(elements); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(elements.errorMsg.style.display).toBe("none"); + expect(receivedPassphrase).toBe(unicodePass); + }); + + it("is case-sensitive when comparing passphrases", () => { + const elements = createPassphraseForm(); + addValidationHandler(elements, "test mnemonic"); + + // Same passphrase but different case + elements.passphraseInput.value = "Password123"; + elements.confirmInput.value = "password123"; + + submitForm(elements); + + expect(elements.errorMsg.style.display).toBe("block"); + expect(elements.errorMsg.innerText).toBe("Passphrases do not match."); + }); });