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.");
+ });
});