diff --git a/export/index.template.html b/export/index.template.html index e9d2324..32d078e 100644 --- a/export/index.template.html +++ b/export/index.template.html @@ -75,6 +75,83 @@ .hidden { display: none; } + #passphrase-form-div { + text-align: center; + max-width: 500px; + } + #passphrase-form-div p { + font-size: 0.9em; + margin-bottom: 1.5em; + } + #passphrase-form-div label { + display: block; + text-align: left; + width: auto; + word-wrap: none; + margin: 0.5em 0 0.25em 0; + } + #passphrase-form-div input[type="password"] { + width: 100%; + padding: 0.5em 0; + font-size: 1em; + border: none; + border-bottom: 1px solid #666; + box-sizing: border-box; + margin-bottom: 0.5em; + background-color: transparent; + outline: none; + } + #encrypt-and-export { + color: white; + width: 100%; + font-size: 1em; + padding: 0.75em; + margin-top: 1em; + border-radius: 8px; + 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-bottom: 0.5em; + 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; + } + .strength-weak { + background-color: #e74c3c; + } + .strength-fair { + background-color: #f39c12; + } + .strength-good { + background-color: #3498db; + } + .strength-strong { + background-color: #27ae60; + } @@ -162,6 +239,37 @@

Message log

+ @@ -1191,6 +1299,106 @@

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. + // Normalize to NFC so the same visual passphrase always produces the + // same bytes regardless of OS/keyboard Unicode decomposition form. + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(passphrase.normalize("NFC")), + "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 (NFC-normalized, matching encryptWithPassphrase). + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(passphrase.normalize("NFC")), + "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 +1429,7 @@

Message log

validateStyles, getSettings, setSettings, + encryptWithPassphrase, }; })(); @@ -1235,6 +1444,16 @@

Message log

// controllers to remove event listeners const messageListenerController = new AbortController(); const turnkeyInitController = new AbortController(); + const passphraseTextEncoder = new TextEncoder(); + + /** + * Holds decrypted plaintext bytes while waiting for passphrase encryption. + * We keep this data in mutable Uint8Array buffers so we can wipe it. + */ + const pendingPassphraseExport = { + plaintextBytes: null, + requestId: null, + }; /** * DOM Event handlers to power the export flow in standalone mode @@ -1285,14 +1504,21 @@

Message log

var messageEventListener = async function (event) { if (event.data && event.data["type"] == "INJECT_KEY_EXPORT_BUNDLE") { TKHQ.logMessage( - `⬇️ Received message ${event.data["type"]}: ${event.data["value"]}, ${event.data["keyFormat"]}, ${event.data["organizationId"]}` + `⬇️ Received message ${event.data["type"]}: ${ + event.data["value"] + }, ${event.data["keyFormat"]}, ${ + event.data["organizationId"] + }, encryptToPassphrase=${Boolean( + event.data["encryptToPassphrase"] + )}` ); try { await onInjectKeyBundle( event.data["value"], event.data["keyFormat"], event.data["organizationId"], - event.data["requestId"] + event.data["requestId"], + Boolean(event.data["encryptToPassphrase"]) ); } catch (e) { TKHQ.sendMessageUp("ERROR", e.toString(), event.data["requestId"]); @@ -1300,18 +1526,31 @@

Message log

} if (event.data && event.data["type"] == "INJECT_WALLET_EXPORT_BUNDLE") { TKHQ.logMessage( - `⬇️ Received message ${event.data["type"]}: ${event.data["value"]}, ${event.data["organizationId"]}` + `⬇️ Received message ${event.data["type"]}: ${ + event.data["value"] + }, ${event.data["organizationId"]}, encryptToPassphrase=${Boolean( + event.data["encryptToPassphrase"] + )}` ); try { await onInjectWalletBundle( event.data["value"], event.data["organizationId"], - event.data["requestId"] + event.data["requestId"], + Boolean(event.data["encryptToPassphrase"]) ); } catch (e) { TKHQ.sendMessageUp("ERROR", e.toString(), event.data["requestId"]); } } + if (event.data && event.data["type"] == "CONFIRM_PASSPHRASE_EXPORT") { + TKHQ.logMessage(`⬇️ Received message ${event.data["type"]}`); + try { + await onConfirmPassphraseExport(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"]); @@ -1349,6 +1588,7 @@

Message log

}); addDOMEventListeners(); + initPassphraseFormHandlers(); if (!messageListenerController.signal.aborted) { // If styles are saved in local storage, sanitize and apply them. @@ -1407,6 +1647,9 @@

Message log

* @param {string} key */ function displayKey(key) { + hidePassphraseForm(); + clearPendingPassphraseExport(); + Array.from(document.body.children).forEach((child) => { if (child.tagName !== "SCRIPT" && child.id !== "key-div") { child.style.display = "none"; @@ -1422,9 +1665,9 @@

Message log

textAlign: "left", }; - // Create a new div with the key material and append the new div to the body const keyDiv = document.getElementById("key-div"); keyDiv.innerText = key; + keyDiv.style.display = "block"; for (let styleKey in style) { keyDiv.style[styleKey] = style[styleKey]; } @@ -1432,6 +1675,338 @@

Message log

TKHQ.applySettings(TKHQ.getSettings()); } + function wipeUint8Array(buf) { + if (!buf) { + return; + } + for (let i = 0; i < buf.length; i++) { + buf[i] = 0; + } + } + + function clearPendingPassphraseExport() { + wipeUint8Array(pendingPassphraseExport.plaintextBytes); + pendingPassphraseExport.plaintextBytes = null; + pendingPassphraseExport.requestId = null; + } + + function getSettingsObject() { + const settings = TKHQ.getSettings(); + if (!settings) { + return null; + } + return typeof settings === "string" ? JSON.parse(settings) : settings; + } + + function applySettingsToPassphraseForm() { + const formDiv = document.getElementById("passphrase-form-div"); + if (!formDiv) { + return; + } + + const settingsObj = getSettingsObject(); + if (!settingsObj || !settingsObj.styles) { + return; + } + + Object.entries(settingsObj.styles).forEach(([key, value]) => { + formDiv.style[key] = value; + }); + } + + function setPassphraseError(message) { + const errorMsg = document.getElementById("passphrase-error"); + if (!errorMsg) { + return; + } + errorMsg.innerText = message; + errorMsg.classList.remove("hidden"); + errorMsg.style.display = "block"; + errorMsg.style.color = "c0392b"; + } + + function clearPassphraseError() { + const errorMsg = document.getElementById("passphrase-error"); + if (!errorMsg) { + return; + } + errorMsg.innerText = ""; + errorMsg.classList.add("hidden"); + errorMsg.style.display = "none"; + } + + /** + * 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 }; + } + + function updatePassphraseStrength(passphrase) { + const strengthFill = document.getElementById( + "passphrase-strength-fill" + ); + const strengthText = document.getElementById( + "passphrase-strength-text" + ); + if (!strengthFill || !strengthText) { + return; + } + + const { score, label, feedback } = + evaluatePassphraseStrength(passphrase); + const strengthClasses = [ + "strength-weak", + "strength-fair", + "strength-good", + "strength-strong", + ]; + + strengthFill.className = ""; + if (score === 0) { + strengthFill.style.width = "0%"; + strengthText.textContent = ""; + return; + } + + strengthFill.style.width = `${score * 25}%`; + strengthFill.classList.add(strengthClasses[Math.min(score - 1, 3)]); + strengthText.textContent = `${label} — ${feedback}`; + } + + function hidePassphraseForm() { + const formDiv = document.getElementById("passphrase-form-div"); + const passphraseInput = document.getElementById("export-passphrase"); + const confirmInput = document.getElementById( + "export-passphrase-confirm" + ); + + if (!formDiv || !passphraseInput || !confirmInput) { + return; + } + + passphraseInput.value = ""; + confirmInput.value = ""; + updatePassphraseStrength(""); + clearPassphraseError(); + + formDiv.classList.add("hidden"); + formDiv.style.display = "none"; + } + + /** + * Show the passphrase form for deferred client-triggered encryption. + * @param {Uint8Array} plaintextBytes + * @param {string} requestId + */ + function displayPassphraseForm(plaintextBytes, requestId) { + const formDiv = document.getElementById("passphrase-form-div"); + const description = document.getElementById("passphrase-description"); + const passphraseInput = document.getElementById("export-passphrase"); + if (!formDiv || !description || !passphraseInput) { + throw new Error("passphrase form is not available in the document"); + } + + const plaintextBuf = + plaintextBytes instanceof Uint8Array + ? new Uint8Array(plaintextBytes) + : new Uint8Array(plaintextBytes || []); + if (plaintextBuf.length === 0) { + throw new Error("cannot show passphrase form for empty plaintext"); + } + + hidePassphraseForm(); + clearPendingPassphraseExport(); + pendingPassphraseExport.plaintextBytes = plaintextBuf; + pendingPassphraseExport.requestId = requestId; + + description.innerText = + "Enter a passphrase to encrypt your key material. You will need this passphrase to decrypt your wallet later."; + + Array.from(document.body.children).forEach((child) => { + if ( + child.tagName !== "SCRIPT" && + child.id !== "passphrase-form-div" + ) { + child.style.display = "none"; + } + }); + + formDiv.classList.remove("hidden"); + formDiv.style.display = "block"; + applySettingsToPassphraseForm(); + passphraseInput.focus(); + } + + /** + * Called by the parent frame to confirm the user is done entering their passphrase. + * Validates the passphrase, encrypts the pending plaintext, and sends the + * base64-encoded encrypted bundle back to the parent via postMessage. + * @param {string} requestId + */ + async function onConfirmPassphraseExport(requestId) { + const passphraseInput = document.getElementById("export-passphrase"); + const confirmInput = document.getElementById( + "export-passphrase-confirm" + ); + if (!passphraseInput || !confirmInput) { + throw new Error("passphrase form is not available in the document"); + } + + if (!pendingPassphraseExport.plaintextBytes) { + throw new Error("no pending export found for passphrase encryption"); + } + + const passphrase = passphraseInput.value; + const confirmPassphrase = confirmInput.value; + if (passphrase.length < 8) { + const errorMessage = "Passphrase must be at least 8 characters long."; + setPassphraseError(errorMessage); + throw new Error(errorMessage); + } + if (passphrase !== confirmPassphrase) { + const errorMessage = "Passphrases do not match."; + setPassphraseError(errorMessage); + throw new Error(errorMessage); + } + + clearPassphraseError(); + + let plaintextCopy; + let encryptedBytes; + try { + plaintextCopy = new Uint8Array( + pendingPassphraseExport.plaintextBytes + ); + encryptedBytes = await TKHQ.encryptWithPassphrase( + plaintextCopy, + passphrase + ); + const encryptedBase64 = btoa( + Array.from(encryptedBytes, (b) => String.fromCharCode(b)).join("") + ); + + wipeUint8Array(plaintextCopy); + wipeUint8Array(encryptedBytes); + plaintextCopy = null; + encryptedBytes = null; + + hidePassphraseForm(); + clearPendingPassphraseExport(); + TKHQ.sendMessageUp( + "PASSPHRASE_ENCRYPTED_BUNDLE", + encryptedBase64, + requestId + ); + } catch (e) { + wipeUint8Array(plaintextCopy); + wipeUint8Array(encryptedBytes); + setPassphraseError("Encryption failed: " + e.toString()); + throw e; + } + } + + function initPassphraseFormHandlers() { + const formDiv = document.getElementById("passphrase-form-div"); + const passphraseInput = document.getElementById("export-passphrase"); + const confirmInput = document.getElementById( + "export-passphrase-confirm" + ); + const submitButton = document.getElementById("encrypt-and-export"); + if (!formDiv || !passphraseInput || !confirmInput || !submitButton) { + return; + } + + if (formDiv.dataset.handlersInitialized === "true") { + return; + } + formDiv.dataset.handlersInitialized = "true"; + hidePassphraseForm(); + const isStandalone = window.parent === window; + submitButton.style.display = isStandalone ? "block" : "none"; + + passphraseInput.addEventListener("input", () => { + updatePassphraseStrength(passphraseInput.value); + }); + + if (isStandalone) { + submitButton.addEventListener("click", async () => { + const requestId = pendingPassphraseExport.requestId; + try { + await onConfirmPassphraseExport(requestId); + } catch (e) { + TKHQ.sendMessageUp("ERROR", e.toString(), requestId); + } + }); + + passphraseInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitButton.click(); + } + }); + confirmInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitButton.click(); + } + }); + } + } + /** * Parse and decrypt the export bundle. * The `bundle` param is a JSON string of the encapsulated public @@ -1525,12 +2100,14 @@

Message log

* @param {string} keyFormat * @param {string} organizationId * @param {string} requestId + * @param {boolean} encryptToPassphrase */ async function onInjectKeyBundle( bundle, keyFormat, organizationId, - requestId + requestId, + encryptToPassphrase = false ) { // Decrypt the export bundle const keyBytes = await decryptBundle(bundle, organizationId); @@ -1542,24 +2119,40 @@

Message log

var key; const privateKeyBytes = new Uint8Array(keyBytes); if (keyFormat === "SOLANA") { - const privateKeyHex = TKHQ.uint8arrayToHexString( - privateKeyBytes.subarray(0, 32) - ); - const publicKeyBytes = TKHQ.getEd25519PublicKey(privateKeyHex); - key = await TKHQ.encodeKey( - privateKeyBytes, - keyFormat, - publicKeyBytes - ); + try { + const privateKeyHex = TKHQ.uint8arrayToHexString( + privateKeyBytes.subarray(0, 32) + ); + const publicKeyBytes = TKHQ.getEd25519PublicKey(privateKeyHex); + key = await TKHQ.encodeKey( + privateKeyBytes, + keyFormat, + publicKeyBytes + ); + } finally { + wipeUint8Array(privateKeyBytes); + } } else { - key = await TKHQ.encodeKey(privateKeyBytes, keyFormat); + try { + key = await TKHQ.encodeKey(privateKeyBytes, keyFormat); + } finally { + wipeUint8Array(privateKeyBytes); + } } - // Display only the key - displayKey(key); + if (encryptToPassphrase) { + const keyBytesToEncrypt = passphraseTextEncoder.encode(key); + displayPassphraseForm(keyBytesToEncrypt, requestId); + wipeUint8Array(keyBytesToEncrypt); + } else { + // Display only the key + displayKey(key); + } // Send up BUNDLE_INJECTED message TKHQ.sendMessageUp("BUNDLE_INJECTED", true, requestId); + + key = ""; } /** @@ -1567,8 +2160,14 @@

Message log

* @param {string} bundle * @param {string} organizationId * @param {string} requestId + * @param {boolean} encryptToPassphrase */ - async function onInjectWalletBundle(bundle, organizationId, requestId) { + async function onInjectWalletBundle( + bundle, + organizationId, + requestId, + encryptToPassphrase = false + ) { // Decrypt the export bundle const walletBytes = await decryptBundle(bundle, organizationId); @@ -1576,13 +2175,28 @@

Message log

TKHQ.onResetEmbeddedKey(); // Parse the decrypted wallet bytes - const wallet = TKHQ.encodeWallet(new Uint8Array(walletBytes)); + const walletBytesUint8 = new Uint8Array(walletBytes); + let wallet; + try { + wallet = TKHQ.encodeWallet(walletBytesUint8); + } finally { + wipeUint8Array(walletBytesUint8); + } - // Display only the wallet's mnemonic - displayKey(wallet.mnemonic); + if (encryptToPassphrase) { + const mnemonicBytes = passphraseTextEncoder.encode(wallet.mnemonic); + displayPassphraseForm(mnemonicBytes, requestId); + wipeUint8Array(mnemonicBytes); + } else { + // Display only the wallet's mnemonic + displayKey(wallet.mnemonic); + } // Send up BUNDLE_INJECTED message TKHQ.sendMessageUp("BUNDLE_INJECTED", true, requestId); + + wallet.mnemonic = ""; + wallet.passphrase = ""; } /** @@ -1600,6 +2214,11 @@

Message log

// Persist in local storage TKHQ.setSettings(validSettings); + const passphraseForm = document.getElementById("passphrase-form-div"); + if (passphraseForm && !passphraseForm.classList.contains("hidden")) { + applySettingsToPassphraseForm(); + } + // Send up SETTINGS_APPLIED message TKHQ.sendMessageUp("SETTINGS_APPLIED", true, requestId); } diff --git a/export/index.test.js b/export/index.test.js index 79648fe..d8e3c3f 100644 --- a/export/index.test.js +++ b/export/index.test.js @@ -8,6 +8,41 @@ const html = fs .readFileSync(path.resolve(__dirname, "./index.template.html"), "utf8") .replace("${TURNKEY_SIGNER_ENVIRONMENT}", "prod"); +/** + * Mirror of the iframe's encryptWithPassphrase, used to verify round-trips in tests. + * Kept here rather than exported from TKHQ to avoid exposing decrypt on the global + * in production. + */ +async function decryptWithPassphrase(encryptedBuf, passphrase) { + const salt = encryptedBuf.slice(0, 16); + const iv = encryptedBuf.slice(16, 28); + const ciphertext = encryptedBuf.slice(28); + + const keyMaterial = await crypto.webcrypto.subtle.importKey( + "raw", + new TextEncoder().encode(passphrase.normalize("NFC")), + "PBKDF2", + false, + ["deriveBits", "deriveKey"] + ); + + const aesKey = await crypto.webcrypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: 600000, hash: "SHA-256" }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"] + ); + + const decrypted = await crypto.webcrypto.subtle.decrypt( + { name: "AES-GCM", iv }, + aesKey, + ciphertext + ); + + return new Uint8Array(decrypted); +} + let dom; let TKHQ; @@ -446,4 +481,406 @@ 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 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( + 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 decryptWithPassphrase(encrypted1, passphrase); + const decrypted2 = await 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 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("contains hidden static passphrase form elements", () => { + const form = document.getElementById("passphrase-form-div"); + expect(form).toBeInTheDocument(); + expect(form.classList.contains("hidden")).toBe(true); + expect( + document.getElementById("passphrase-description") + ).toBeInTheDocument(); + expect(document.getElementById("export-passphrase")).toBeInTheDocument(); + expect( + document.getElementById("export-passphrase-confirm") + ).toBeInTheDocument(); + expect(document.getElementById("encrypt-and-export")).toBeInTheDocument(); + }); + + 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."); + }); });