diff --git a/export/index.template.html b/export/index.template.html index 98ad026..ba4fd46 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,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, @@ -858,6 +1014,8 @@

Message log

validateStyles, getSettings, setSettings, + encryptWithPassphrase, + decryptWithPassphrase, }; })(); @@ -949,6 +1107,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 +1241,135 @@

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)"; + passphraseInput.required = true; + passphraseInput.setAttribute("aria-required", "true"); + passphraseInput.minLength = 8; + 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"; + confirmInput.required = true; + confirmInput.setAttribute("aria-required", "true"); + 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 and disable button to prevent duplicate submissions + errorMsg.style.display = "none"; + submitButton.disabled = true; + + 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 + ); + + // Keep button disabled after success (operation complete) + } catch (e) { + 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 @@ -1222,6 +1523,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..8c1fa81 100644 --- a/export/index.test.js +++ b/export/index.test.js @@ -385,4 +385,370 @@ 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); + }); +}); + +/** + * 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."); + }); });