From 8124bc049fbdc0e401d2c1255d33cd7475f096f6 Mon Sep 17 00:00:00 2001 From: fainashalts Date: Mon, 5 Jan 2026 16:50:38 -0800 Subject: [PATCH 1/9] add passphrase encryption for wallet export in iframe --- export/index.template.html | 314 +++++++++++++++++++++++++++++++++++++ export/index.test.js | 97 ++++++++++++ 2 files changed, 411 insertions(+) diff --git a/export/index.template.html b/export/index.template.html index 98ad026..2083edd 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -75,6 +75,64 @@ .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; + } @@ -831,6 +889,102 @@

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 (100,000 iterations, SHA-256) + const aesKey = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 100000, + 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: 100000, + 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, @@ -858,6 +1012,8 @@

Message log

validateStyles, getSettings, setSettings, + encryptWithPassphrase, + decryptWithPassphrase, }; })(); @@ -949,6 +1105,20 @@

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"]); @@ -1069,6 +1239,126 @@

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("div"); + 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)"; + formDiv.appendChild(passphraseInput); + + // 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"; + 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 = "button"; + submitButton.id = "encrypt-and-export"; + submitButton.innerText = "Encrypt & Export"; + formDiv.appendChild(submitButton); + + // Append the form to the body + document.body.appendChild(formDiv); + + // Add click event listener to the submit button + submitButton.addEventListener("click", async () => { + 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"; + + try { + // Encode mnemonic to Uint8Array + const encoder = new TextEncoder(); + const mnemonicBytes = encoder.encode(mnemonic); + + // Encrypt with passphrase + const encryptedBytes = await TKHQ.encryptWithPassphrase( + mnemonicBytes, + passphrase + ); + + // Convert to base64 + const encryptedBase64 = btoa( + String.fromCharCode.apply(null, encryptedBytes) + ); + + // Send message up + TKHQ.sendMessageUp( + "ENCRYPTED_WALLET_EXPORT", + encryptedBase64, + requestId + ); + } catch (e) { + errorMsg.innerText = "Encryption failed: " + e.toString(); + errorMsg.style.display = "block"; + } + }); + } + /** * Parse and decrypt the export bundle. * The `bundle` param is a JSON string of the encapsulated public @@ -1222,6 +1512,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 abe9230..a003f07 100644 --- a/export/index.test.js +++ b/export/index.test.js @@ -385,4 +385,101 @@ 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(String.fromCharCode.apply(null, encrypted)); + 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); + }); }); From 12d670af46546ae2ca32bc797812946217668452 Mon Sep 17 00:00:00 2001 From: Faina Shalts Date: Mon, 5 Jan 2026 21:14:45 -0800 Subject: [PATCH 2/9] Update export/index.template.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- export/index.template.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/export/index.template.html b/export/index.template.html index 2083edd..9730ad2 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -909,12 +909,14 @@

Message log

["deriveBits", "deriveKey"] ); - // Derive AES-256 key using PBKDF2 (100,000 iterations, SHA-256) + // 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: 100000, + iterations: 600000, hash: "SHA-256", }, keyMaterial, From 8aa6e03c6e79b7fa0d0ae91c99ac61bb09f08c18 Mon Sep 17 00:00:00 2001 From: fainashalts Date: Tue, 6 Jan 2026 07:04:43 -0800 Subject: [PATCH 3/9] make encrypt & decrypt iterations match --- export/index.template.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/export/index.template.html b/export/index.template.html index 9730ad2..b3b514b 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -968,7 +968,7 @@

Message log

{ name: "PBKDF2", salt: salt, - iterations: 100000, + iterations: 600000, hash: "SHA-256", }, keyMaterial, From 0d60a1db9cdd99d38e0137fdf6a4b4d0ff7e3f73 Mon Sep 17 00:00:00 2001 From: fainashalts Date: Tue, 6 Jan 2026 07:12:18 -0800 Subject: [PATCH 4/9] require passphrase when exporting with encryption --- export/index.template.html | 5 + export/index.test.js | 269 +++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) diff --git a/export/index.template.html b/export/index.template.html index b3b514b..62be0aa 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -1279,6 +1279,9 @@

Message log

passphraseInput.type = "password"; passphraseInput.id = "export-passphrase"; passphraseInput.placeholder = "Enter passphrase (min 8 characters)"; + passphraseInput.required = true; + passphraseInput.setAttribute("aria-required", "true"); + passphraseInput.minLength = 8; formDiv.appendChild(passphraseInput); // Create confirmation input @@ -1291,6 +1294,8 @@

Message log

confirmInput.type = "password"; confirmInput.id = "export-passphrase-confirm"; confirmInput.placeholder = "Confirm passphrase"; + confirmInput.required = true; + confirmInput.setAttribute("aria-required", "true"); formDiv.appendChild(confirmInput); // Create error message paragraph diff --git a/export/index.test.js b/export/index.test.js index a003f07..8c1fa81 100644 --- a/export/index.test.js +++ b/export/index.test.js @@ -483,3 +483,272 @@ describe("TKHQ", () => { 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("div"); + 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 = "button"; + submitButton.id = "encrypt-and-export"; + formDiv.appendChild(submitButton); + + document.body.appendChild(formDiv); + + return { formDiv, passphraseInput, confirmInput, errorMsg, submitButton }; + } + + // Helper to create click handler that mimics displayPassphraseForm logic + function addValidationHandler(elements, mnemonic, onSuccess) { + const { passphraseInput, confirmInput, errorMsg, submitButton } = elements; + + submitButton.addEventListener("click", async () => { + 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"; + + // Click submit + elements.submitButton.click(); + + // 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"; + + elements.submitButton.click(); + + 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"; + + elements.submitButton.click(); + + // 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"; + + elements.submitButton.click(); + + 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"; + + elements.submitButton.click(); + + // 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"; + elements.submitButton.click(); + expect(elements.errorMsg.style.display).toBe("block"); + + // Now enter valid passphrases + elements.passphraseInput.value = "validpassword123"; + elements.confirmInput.value = "validpassword123"; + elements.submitButton.click(); + + // 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 = ""; + + elements.submitButton.click(); + + // 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; + + elements.submitButton.click(); + + 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; + + elements.submitButton.click(); + + 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"; + + elements.submitButton.click(); + + expect(elements.errorMsg.style.display).toBe("block"); + expect(elements.errorMsg.innerText).toBe("Passphrases do not match."); + }); +}); From 24735d8484eb493102c632467f661babe7a1fb1e Mon Sep 17 00:00:00 2001 From: fainashalts Date: Tue, 6 Jan 2026 07:31:55 -0800 Subject: [PATCH 5/9] add button state management --- export/index.template.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/export/index.template.html b/export/index.template.html index 62be0aa..ba4fd46 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -1334,8 +1334,9 @@

Message log

return; } - // Hide error message + // Hide error message and disable button to prevent duplicate submissions errorMsg.style.display = "none"; + submitButton.disabled = true; try { // Encode mnemonic to Uint8Array @@ -1359,9 +1360,12 @@

Message log

encryptedBase64, requestId ); + + // Keep button disabled after success (operation complete) } catch (e) { errorMsg.innerText = "Encryption failed: " + e.toString(); errorMsg.style.display = "block"; + submitButton.disabled = false; } }); } From 6b885fc263d7b674fd4beab5dedbfd3f009913e4 Mon Sep 17 00:00:00 2001 From: fainashalts Date: Wed, 4 Feb 2026 10:56:54 -0800 Subject: [PATCH 6/9] fix: resolve bugs and clean up unused code --- export-and-sign/package-lock.json | 42 +++++++++-------------------- export-and-sign/src/turnkey-core.js | 3 +-- export/package-lock.json | 8 ++++++ import/package-lock.json | 12 ++++++++- import/src/turnkey-core.js | 5 ++-- 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/export-and-sign/package-lock.json b/export-and-sign/package-lock.json index 1ea6f71..b1523d8 100644 --- a/export-and-sign/package-lock.json +++ b/export-and-sign/package-lock.json @@ -90,6 +90,7 @@ "integrity": "sha512-97z/ju/Jy1rZmDxybphrBuI+jtJjFVoz7Mr9yUQVVVi+DNZE333uFQeMOqcCIy1x3WYBIbWftUSLmbNXNT7qFQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", @@ -2026,6 +2027,7 @@ "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -3724,6 +3726,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3799,6 +3802,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4510,6 +4514,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4599,20 +4604,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -6931,6 +6922,7 @@ "integrity": "sha512-6YrDKTuqaP/TquFH7h4srYWsZx+x6k6+FbsTm0ziCwGHDP78Unr1r9F/H4+sGmMbX08GQcJ+K64x55b+7VM/jg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/html-minifier-terser": "^6.0.0", "html-minifier-terser": "^6.0.2", @@ -7893,6 +7885,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -10461,6 +10454,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12282,7 +12276,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsyringe": { "version": "4.10.0", @@ -12486,20 +12481,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -12621,6 +12602,7 @@ "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -12670,6 +12652,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -13183,6 +13166,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, diff --git a/export-and-sign/src/turnkey-core.js b/export-and-sign/src/turnkey-core.js index 6cbe8c7..fdedd4a 100644 --- a/export-and-sign/src/turnkey-core.js +++ b/export-and-sign/src/turnkey-core.js @@ -439,7 +439,6 @@ function base58Encode(bytes) { function base58Decode(s) { // See https://en.bitcoin.it/wiki/Base58Check_encoding var alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - var decoded = BigInt(0); var decodedBytes = []; var leadingZeros = []; for (var i = 0; i < s.length; i++) { @@ -532,7 +531,7 @@ function getEd25519PublicKey(privateKeyHex) { * @param {Object} styles * @return {Object} */ -function validateStyles(styles, element) { +function validateStyles(styles) { const validStyles = {}; const cssValidationRegex = { diff --git a/export/package-lock.json b/export/package-lock.json index fc0a0ce..87a9876 100644 --- a/export/package-lock.json +++ b/export/package-lock.json @@ -71,6 +71,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", @@ -2044,6 +2045,7 @@ "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -3381,6 +3383,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -5233,6 +5236,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9180,6 +9184,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", "dev": true, + "peer": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", @@ -10553,6 +10558,7 @@ "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, + "peer": true, "requires": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -11570,6 +11576,7 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", "dev": true, + "peer": true, "requires": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -12887,6 +12894,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "requires": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", diff --git a/import/package-lock.json b/import/package-lock.json index b52e462..bf1f6ea 100644 --- a/import/package-lock.json +++ b/import/package-lock.json @@ -82,6 +82,7 @@ "integrity": "sha512-97z/ju/Jy1rZmDxybphrBuI+jtJjFVoz7Mr9yUQVVVi+DNZE333uFQeMOqcCIy1x3WYBIbWftUSLmbNXNT7qFQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", @@ -1949,6 +1950,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -3323,6 +3325,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3377,6 +3380,7 @@ "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -3969,6 +3973,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -7107,6 +7112,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9734,6 +9740,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11392,7 +11399,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/type-detect": { "version": "4.0.8", @@ -11659,6 +11667,7 @@ "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -11708,6 +11717,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/import/src/turnkey-core.js b/import/src/turnkey-core.js index 5abec0c..8811f3e 100644 --- a/import/src/turnkey-core.js +++ b/import/src/turnkey-core.js @@ -155,7 +155,6 @@ function uint8arrayToHexString(buffer) { function base58Decode(s) { // See https://en.bitcoin.it/wiki/Base58Check_encoding var alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - var decoded = BigInt(0); var decodedBytes = []; var leadingZeros = []; for (var i = 0; i < s.length; i++) { @@ -208,7 +207,7 @@ function decodeKey(privateKey, keyFormat) { const decodedKeyBytes = base58Decode(privateKey); if (decodedKeyBytes.length !== 64) { throw new Error( - `invalid key length. Expected 64 bytes. Got ${decodedKeyBytes.length()}.` + `invalid key length. Expected 64 bytes. Got ${decodedKeyBytes.length}.` ); } return decodedKeyBytes.subarray(0, 32); @@ -517,7 +516,7 @@ function applySettings(settings) { if (settingsObj.passphraseStyles) { // Validate, sanitize, and apply the styles to the "passphrase" textarea. - const validStyles = TKHQ.validateStyles(settingsObj.passphraseStyles); + const validStyles = validateStyles(settingsObj.passphraseStyles); Object.entries(validStyles).forEach(([key, value]) => { passphraseTextarea.style[key] = value; }); From 1dfe4c56170210ef0a6b5474d345ec131d8f3a15 Mon Sep 17 00:00:00 2001 From: fainashalts Date: Wed, 4 Feb 2026 13:31:12 -0800 Subject: [PATCH 7/9] add new-password autocomplete --- export/index.template.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/export/index.template.html b/export/index.template.html index ba4fd46..54a9701 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -1281,6 +1281,7 @@

Message log

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); @@ -1296,6 +1297,7 @@

Message log

confirmInput.placeholder = "Confirm passphrase"; confirmInput.required = true; confirmInput.setAttribute("aria-required", "true"); + confirmInput.setAttribute("autocomplete", "new-password"); formDiv.appendChild(confirmInput); // Create error message paragraph From c4f862e12aeba3f92ff9376588e9892243db0df2 Mon Sep 17 00:00:00 2001 From: fainashalts Date: Wed, 4 Feb 2026 17:20:36 -0800 Subject: [PATCH 8/9] refactor: use form element for passphrase input --- export/index.template.html | 9 +++++---- export/index.test.js | 41 ++++++++++++++++++++++---------------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/export/index.template.html b/export/index.template.html index 54a9701..a805f61 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -1255,7 +1255,7 @@

Message log

}); // Create the passphrase form container - const formDiv = document.createElement("div"); + const formDiv = document.createElement("form"); formDiv.id = "passphrase-form-div"; // Create heading @@ -1308,7 +1308,7 @@

Message log

// Create submit button const submitButton = document.createElement("button"); - submitButton.type = "button"; + submitButton.type = "submit"; submitButton.id = "encrypt-and-export"; submitButton.innerText = "Encrypt & Export"; formDiv.appendChild(submitButton); @@ -1316,8 +1316,9 @@

Message log

// Append the form to the body document.body.appendChild(formDiv); - // Add click event listener to the submit button - submitButton.addEventListener("click", async () => { + // Add submit event listener to the form + formDiv.addEventListener("submit", async (event) => { + event.preventDefault(); const passphrase = passphraseInput.value; const confirmPassphrase = confirmInput.value; diff --git a/export/index.test.js b/export/index.test.js index 8c1fa81..674e1f9 100644 --- a/export/index.test.js +++ b/export/index.test.js @@ -495,7 +495,7 @@ describe("Passphrase Form Validation", () => { // Helper to create the passphrase form elements (mimics displayPassphraseForm) function createPassphraseForm() { - const formDiv = document.createElement("div"); + const formDiv = document.createElement("form"); formDiv.id = "passphrase-form-div"; const passphraseInput = document.createElement("input"); @@ -514,7 +514,7 @@ describe("Passphrase Form Validation", () => { formDiv.appendChild(errorMsg); const submitButton = document.createElement("button"); - submitButton.type = "button"; + submitButton.type = "submit"; submitButton.id = "encrypt-and-export"; formDiv.appendChild(submitButton); @@ -523,11 +523,18 @@ describe("Passphrase Form Validation", () => { return { formDiv, passphraseInput, confirmInput, errorMsg, submitButton }; } - // Helper to create click handler that mimics displayPassphraseForm logic + // 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 { passphraseInput, confirmInput, errorMsg, submitButton } = elements; + const { formDiv, passphraseInput, confirmInput, errorMsg } = elements; - submitButton.addEventListener("click", async () => { + formDiv.addEventListener("submit", async (event) => { + event.preventDefault(); const passphrase = passphraseInput.value; const confirmPassphrase = confirmInput.value; @@ -580,8 +587,8 @@ describe("Passphrase Form Validation", () => { elements.passphraseInput.value = "short"; elements.confirmInput.value = "short"; - // Click submit - elements.submitButton.click(); + // Submit form + submitForm(elements); // Error should be displayed expect(elements.errorMsg.style.display).toBe("block"); @@ -598,7 +605,7 @@ describe("Passphrase Form Validation", () => { elements.passphraseInput.value = "1234567"; elements.confirmInput.value = "1234567"; - elements.submitButton.click(); + submitForm(elements); expect(elements.errorMsg.style.display).toBe("block"); expect(elements.errorMsg.innerText).toBe( @@ -618,7 +625,7 @@ describe("Passphrase Form Validation", () => { elements.passphraseInput.value = "12345678"; elements.confirmInput.value = "12345678"; - elements.submitButton.click(); + submitForm(elements); // Allow async handler to complete await new Promise((resolve) => setTimeout(resolve, 10)); @@ -635,7 +642,7 @@ describe("Passphrase Form Validation", () => { elements.passphraseInput.value = "password123"; elements.confirmInput.value = "password456"; - elements.submitButton.click(); + submitForm(elements); expect(elements.errorMsg.style.display).toBe("block"); expect(elements.errorMsg.innerText).toBe("Passphrases do not match."); @@ -649,7 +656,7 @@ describe("Passphrase Form Validation", () => { elements.passphraseInput.value = "short"; elements.confirmInput.value = "diff"; - elements.submitButton.click(); + submitForm(elements); // Length error should take precedence expect(elements.errorMsg.style.display).toBe("block"); @@ -665,13 +672,13 @@ describe("Passphrase Form Validation", () => { // First trigger an error elements.passphraseInput.value = "short"; elements.confirmInput.value = "short"; - elements.submitButton.click(); + submitForm(elements); expect(elements.errorMsg.style.display).toBe("block"); // Now enter valid passphrases elements.passphraseInput.value = "validpassword123"; elements.confirmInput.value = "validpassword123"; - elements.submitButton.click(); + submitForm(elements); // Allow async handler to complete await new Promise((resolve) => setTimeout(resolve, 10)); @@ -688,7 +695,7 @@ describe("Passphrase Form Validation", () => { elements.passphraseInput.value = "short"; elements.confirmInput.value = ""; - elements.submitButton.click(); + submitForm(elements); // Should show length error, not mismatch expect(elements.errorMsg.innerText).toBe( @@ -709,7 +716,7 @@ describe("Passphrase Form Validation", () => { elements.passphraseInput.value = specialPass; elements.confirmInput.value = specialPass; - elements.submitButton.click(); + submitForm(elements); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -730,7 +737,7 @@ describe("Passphrase Form Validation", () => { elements.passphraseInput.value = unicodePass; elements.confirmInput.value = unicodePass; - elements.submitButton.click(); + submitForm(elements); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -746,7 +753,7 @@ describe("Passphrase Form Validation", () => { elements.passphraseInput.value = "Password123"; elements.confirmInput.value = "password123"; - elements.submitButton.click(); + submitForm(elements); expect(elements.errorMsg.style.display).toBe("block"); expect(elements.errorMsg.innerText).toBe("Passphrases do not match."); From cf9d7b4fee37e554e3a67cf3f7323616c2f7ee82 Mon Sep 17 00:00:00 2001 From: fainashalts Date: Wed, 4 Feb 2026 17:32:46 -0800 Subject: [PATCH 9/9] add passphrase strength indicator --- export/index.template.html | 129 +++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/export/index.template.html b/export/index.template.html index a805f61..c5f1353 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -133,6 +133,30 @@ 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; } @@ -1285,6 +1309,95 @@

Message log

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"); @@ -1357,6 +1470,14 @@

Message log

String.fromCharCode.apply(null, encryptedBytes) ); + // Clear passphrase fields for security before sending message + passphraseInput.value = ""; + confirmInput.value = ""; + // Reset strength indicator + strengthFill.style.width = "0%"; + strengthFill.className = ""; + strengthText.textContent = ""; + // Send message up TKHQ.sendMessageUp( "ENCRYPTED_WALLET_EXPORT", @@ -1366,6 +1487,14 @@

Message log

// Keep button disabled after success (operation complete) } catch (e) { + // 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;