-
Notifications
You must be signed in to change notification settings - Fork 2
Add passphrase-encrypted wallet export flow #110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces a passphrase-encrypted wallet export flow that encrypts wallet mnemonics with a user-provided passphrase before transmission from the iframe. Users enter and confirm a passphrase through a new UI form, and the encrypted data is sent to the parent frame as base64-encoded content instead of plaintext.
Key changes:
- Implements AES-GCM-256 encryption with PBKDF2 key derivation (100,000 iterations)
- Adds a new
INJECT_WALLET_EXPORT_BUNDLE_ENCRYPTEDmessage type and corresponding handler - Creates a passphrase form UI with validation (8-character minimum, matching confirmation)
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 11 comments.
| File | Description |
|---|---|
| export/index.template.html | Adds encryption/decryption utilities, passphrase form UI with styling, and message handler for encrypted wallet export flow |
| export/index.test.js | Adds 5 unit tests covering encryption, decryption, wrong passphrase handling, salt/IV randomness, and end-to-end base64 encoding |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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); |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The input fields lack the required attribute, which means they won't receive native browser validation feedback. While custom validation is implemented in JavaScript, adding the required attribute would provide an additional layer of validation and improve accessibility by allowing screen readers to announce that these fields are required.
| // 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; | ||
| } |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The passphrase validation only checks for a minimum length of 8 characters but doesn't enforce any character composition requirements (e.g., mix of letters, numbers, special characters) or check for common weak passphrases. While this gives users flexibility, consider adding a warning or recommendation for stronger passphrases, especially since this protects sensitive wallet mnemonics.
Alternatively, you could provide visual feedback on passphrase strength to help users make informed decisions.
| // Convert to base64 | ||
| const encryptedBase64 = btoa( | ||
| String.fromCharCode.apply(null, encryptedBytes) | ||
| ); |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The use of String.fromCharCode.apply(null, encryptedBytes) can cause a "Maximum call stack size exceeded" error when the encrypted data is large (typically when arrays exceed ~65k elements). This is because apply unpacks all array elements as function arguments, which can overflow the JavaScript call stack.
Consider using a safer approach that processes the array in chunks or uses a loop to build the string incrementally.
| // Convert to base64 | |
| const encryptedBase64 = btoa( | |
| String.fromCharCode.apply(null, encryptedBytes) | |
| ); | |
| // Convert to base64 without risking call stack overflow | |
| let binary = ""; | |
| const CHUNK_SIZE = 0x8000; // 32,768 bytes per chunk | |
| for (let i = 0; i < encryptedBytes.length; i += CHUNK_SIZE) { | |
| const chunk = encryptedBytes.subarray(i, i + CHUNK_SIZE); | |
| binary += String.fromCharCode.apply(null, chunk); | |
| } | |
| const encryptedBase64 = btoa(binary); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we're only exporting wallets with this functionality. I think this suggestion is overkill.
| const encrypted = await TKHQ.encryptWithPassphrase(mnemonicBytes, passphrase); | ||
|
|
||
| // Convert to base64 (as would be done in displayPassphraseForm) | ||
| const encryptedBase64 = btoa(String.fromCharCode.apply(null, encrypted)); |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The same String.fromCharCode.apply(null, encrypted) pattern is used here in the test, which can fail with a "Maximum call stack size exceeded" error for large encrypted data. While test data might currently be small enough, this creates a mismatch between test code and production code behavior, and the test won't catch the potential production issue.
Consider using the same safer base64 encoding approach in tests that should be used in production code.
| const encryptedBase64 = btoa(String.fromCharCode.apply(null, encrypted)); | |
| const encryptedBase64 = btoa( | |
| Array.from(encrypted) | |
| .map((byte) => String.fromCharCode(byte)) | |
| .join("") | |
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
still think this is overkill
|
|
||
| const encrypted = await TKHQ.encryptWithPassphrase(plaintext, passphrase); | ||
|
|
||
| // Result should be salt (16) + iv (12) + ciphertext (at least as long as plaintext + auth tag) |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment mentions "at least as long as plaintext + auth tag" but doesn't specify the auth tag length. AES-GCM typically uses a 16-byte (128-bit) authentication tag. Consider updating the comment to be more specific, e.g., "salt (16) + iv (12) + ciphertext (plaintext + 16-byte auth tag)" for clarity.
| // Result should be salt (16) + iv (12) + ciphertext (at least as long as plaintext + auth tag) | |
| // Result should be salt (16) + iv (12) + ciphertext (plaintext + 16-byte auth tag) |
| 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 () => { |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The passphrase inputs and submit button are not wrapped in a <form> element. While this works functionally, using a proper form element would improve semantics and accessibility. It would also allow users to submit by pressing Enter in the input fields, which is a common user expectation.
Consider wrapping the inputs and button in a <form> element and handling the submit event instead of the button click event.
| 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 form = document.createElement("form"); | |
| formDiv.appendChild(form); | |
| const confirmLabel = document.createElement("label"); | |
| confirmLabel.setAttribute("for", "export-passphrase-confirm"); | |
| confirmLabel.innerText = "Confirm Passphrase"; | |
| form.appendChild(confirmLabel); | |
| const confirmInput = document.createElement("input"); | |
| confirmInput.type = "password"; | |
| confirmInput.id = "export-passphrase-confirm"; | |
| confirmInput.placeholder = "Confirm passphrase"; | |
| form.appendChild(confirmInput); | |
| // Create error message paragraph | |
| const errorMsg = document.createElement("p"); | |
| errorMsg.id = "passphrase-error"; | |
| errorMsg.style.display = "none"; | |
| form.appendChild(errorMsg); | |
| // Create submit button | |
| const submitButton = document.createElement("button"); | |
| submitButton.type = "submit"; | |
| submitButton.id = "encrypt-and-export"; | |
| submitButton.innerText = "Encrypt & Export"; | |
| form.appendChild(submitButton); | |
| // Append the form container to the body | |
| document.body.appendChild(formDiv); | |
| // Add submit event listener to the form | |
| form.addEventListener("submit", async (event) => { | |
| event.preventDefault(); |
| encryptedBase64, | ||
| requestId | ||
| ); | ||
| } catch (e) { |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an encryption error occurs, the passphrase values remain in the input fields, which could be a security concern if the iframe is still accessible. Consider clearing the passphrase input fields even when an error occurs to prevent potential exposure of the passphrase.
| } catch (e) { | |
| } catch (e) { | |
| // Clear sensitive passphrase inputs on error to avoid lingering secrets in the DOM | |
| passphraseInput.value = ""; | |
| confirmInput.value = ""; |
| 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); |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The passphrase input fields are missing proper autocomplete attributes. For password fields, it's recommended to use autocomplete="new-password" for the initial passphrase field and autocomplete="new-password" for the confirmation field to help password managers correctly identify and handle these fields.
Additionally, consider adding autocomplete="off" if you specifically want to prevent browsers from suggesting saved passwords, though this may reduce usability.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Add passphrase-encrypted wallet export flow
Note: this PR is generated by Cursor using Claude Opus 4.5 based on the details provided in REQ-275. This is step 1, with additional work needed in the SDK and an example to be enumerated in mono.
Summary
This PR adds a new encrypted wallet export flow that allows users to encrypt their wallet mnemonic with a passphrase before it leaves the iframe. Instead of displaying the plaintext mnemonic in the DOM, users are prompted to enter and confirm a passphrase, and the encrypted result is sent to the parent frame as base64-encoded data.
Changes
New Encryption Utilities (TKHQ Module)
encryptWithPassphrase(buf, passphrase)- Encrypts aUint8Arrayusing:salt (16 bytes) || iv (12 bytes) || ciphertextdecryptWithPassphrase(encryptedBuf, passphrase)- Decrypts data encrypted by the above functionNew Message Type
INJECT_WALLET_EXPORT_BUNDLE_ENCRYPTED- New message type that triggers the passphrase-protected export flow instead of displaying the mnemonic directlyNew UI Component
displayPassphraseForm(mnemonic, requestId)- Renders a form with:New Output Message
ENCRYPTED_WALLET_EXPORT- Sent to parent frame with base64-encoded encrypted wallet data upon successful encryptionStyling
Testing
Added 5 new tests:
encryptWithPassphrasecorrectlyAll 23 tests passing.
Usage
Parent frame sends:
Parent frame receives (after user enters passphrase):
Security Notes