Skip to content

Conversation

@fainashalts
Copy link

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 a Uint8Array using:

    • PBKDF2 key derivation (100,000 iterations, SHA-256)
    • AES-GCM-256 encryption
    • Returns concatenated salt (16 bytes) || iv (12 bytes) || ciphertext
  • decryptWithPassphrase(encryptedBuf, passphrase) - Decrypts data encrypted by the above function

New Message Type

  • INJECT_WALLET_EXPORT_BUNDLE_ENCRYPTED - New message type that triggers the passphrase-protected export flow instead of displaying the mnemonic directly

New UI Component

  • displayPassphraseForm(mnemonic, requestId) - Renders a form with:
    • Password input field
    • Password confirmation field
    • Validation (minimum 8 characters, passwords must match)
    • Error message display
    • "Encrypt & Export" submit button

New Output Message

  • ENCRYPTED_WALLET_EXPORT - Sent to parent frame with base64-encoded encrypted wallet data upon successful encryption

Styling

  • Added CSS styles for the passphrase form container, inputs, button, and error messages

Testing

Added 5 new tests:

  • ✅ Encrypts data with passphrase correctly
  • ✅ Decrypts data encrypted by encryptWithPassphrase correctly
  • ✅ Fails to decrypt with wrong passphrase
  • ✅ Produces different ciphertext for same plaintext (verifies random salt/IV)
  • ✅ Handles encryption of wallet mnemonic end-to-end (including base64 encoding)

All 23 tests passing.

Usage

Parent frame sends:

iframe.postMessage({
  type: "INJECT_WALLET_EXPORT_BUNDLE_ENCRYPTED",
  value: bundleString,
  organizationId: orgId,
  requestId: requestId
});

Parent frame receives (after user enters passphrase):

// On success:
{ type: "ENCRYPTED_WALLET_EXPORT", value: "<base64-encoded-encrypted-data>", requestId: "..." }

// On error:
{ type: "ERROR", value: "<error-message>", requestId: "..." }

Security Notes

  • Passphrase never leaves the iframe
  • Uses Web Crypto API for all cryptographic operations
  • PBKDF2 with 100k iterations provides reasonable protection against brute-force attacks
  • Random salt and IV ensure identical passphrases produce different ciphertexts
  • Embedded key is reset after bundle decryption (consistent with existing behavior)

Copy link

Copilot AI left a 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_ENCRYPTED message 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.

Comment on lines 1276 to 1292
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);
Copy link

Copilot AI Jan 6, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1315 to +1321
// 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;
}
Copy link

Copilot AI Jan 6, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1344 to +1347
// Convert to base64
const encryptedBase64 = btoa(
String.fromCharCode.apply(null, encryptedBytes)
);
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
// 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);

Copilot uses AI. Check for mistakes.
Copy link
Author

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));
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
const encryptedBase64 = btoa(String.fromCharCode.apply(null, encrypted));
const encryptedBase64 = btoa(
Array.from(encrypted)
.map((byte) => String.fromCharCode(byte))
.join("")
);

Copilot uses AI. Check for mistakes.
Copy link
Author

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)
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
Comment on lines 1283 to 1311
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 () => {
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
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();

Copilot uses AI. Check for mistakes.
encryptedBase64,
requestId
);
} catch (e) {
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
} catch (e) {
} catch (e) {
// Clear sensitive passphrase inputs on error to avoid lingering secrets in the DOM
passphraseInput.value = "";
confirmInput.value = "";

Copilot uses AI. Check for mistakes.
Comment on lines 1276 to 1292
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);
Copy link

Copilot AI Jan 6, 2026

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants