Skip to content

Add passphrase-encrypted wallet export flow#110

Open
fainashalts wants to merge 15 commits intomainfrom
encrypt-wallet-export
Open

Add passphrase-encrypted wallet export flow#110
fainashalts wants to merge 15 commits intomainfrom
encrypt-wallet-export

Conversation

@fainashalts
Copy link

@fainashalts fainashalts commented Jan 6, 2026

Add passphrase-encrypted wallet export flow

Note: this PR was initially generated by Cursor using Claude Opus 4.5 based on the details provided in REQ-275. I have since made multiple edits and fixes. 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 (600,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

  • CONFIRM_PASSPHRASE_EXPORT - New message type that kicks off the export bundle encryption logic
    • This event would be called from a button on the parent frame

Updated Message Types

  • INJECT_WALLET_EXPORT_BUNDLE & INJECT_KEY_EXPORT_BUNDLE
    • Added an encryptToPassphrase optional param, this pops up the passphrase encryption flow instead of displaying the mnemonic/key material in the DOM right away
    • User inputs a passphrase
    • Mnemonic/key material is encrypted to that passphrase
    • Encrypted bundle is returned to the parent frame as a base64 encoded string

New UI Components

  • displayPassphraseForm(plantextBytes, requestId) - Renders a form with:
    • Password input field
    • Password confirmation field
    • Validation (minimum 8 characters, passwords must match)
    • Error message display

displayPassphraseForm(plaintextBytes, requestId) - Renders a <form> with:

  • Password input field with autocomplete="new-password" and required
  • Password confirmation field with autocomplete="new-password" and required
  • Passphrase strength indicator (Weak / Medium / Strong)
  • Validation (minimum 8 characters, passwords must match)
  • Error message display

New Output Message

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

Styling

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

Testing

Encryption tests (5):
✅ Encrypts data with passphrase correctly
✅ Decrypts data encrypted by encryptWithPassphrase correctly
✅ Fails to decrypt with wrong passphrase
✅ Produces different ciphertext for same plaintext (random salt/IV)
✅ Handles encryption of wallet mnemonic end-to-end
Passphrase Form Validation tests (10):
✅ Shows error when passphrase is too short
✅ Shows error when passphrase is exactly 7 characters
✅ Accepts passphrase with exactly 8 characters
✅ Shows error when passphrases do not match
✅ Shows length error before mismatch error
✅ Hides error message on successful validation
✅ Accepts empty confirmation when passphrase is too short (length check first)
✅ Validates with special characters in passphrase
✅ Validates with unicode characters in passphrase
✅ Is case-sensitive when comparing passphrases

All 33 tests passing.

Usage

Parent frame sends:

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

<user does their thing>

iframe.postMessage({
  type: "CONFIRM_PASSPHRASE_EXPORT",
  requestId: requestId
)}

Parent frame receives (after user enters passphrase):

// On success:
{ type: "PASSPHRASE_ENCRYPTED_BUNDLE", 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 600k 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)
  • Sensitive data (mnemonic bytes, encrypted bytes, passphrase inputs) is zeroed/cleared from memory and the DOM after encryption
  • Base64 encoding uses Array.from instead of String.fromCharCode.apply to prevent stack overflow on large payloads

Quick Demo

https://www.loom.com/share/9c5423f11fd341abb85b6071fe5acacd

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.

@fainashalts fainashalts force-pushed the encrypt-wallet-export branch 2 times, most recently from 815a958 to ec9eac5 Compare February 7, 2026 05:46
@fainashalts fainashalts force-pushed the encrypt-wallet-export branch from ec9eac5 to 8773586 Compare February 7, 2026 05:48
@fainashalts fainashalts marked this pull request as ready for review February 18, 2026 18:34
* base64-encoded encrypted bundle back to the parent via postMessage.
* @param {string} requestId
*/
async function onConfirmPassphraseExport(requestId) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a second export request arrives before the first is confirmed, displayPassphraseForm overwrites pendingPassphraseExport. onConfirmPassphraseExport can then reply with requestId from the first request while encrypting plaintext from the second.

In this function we can require requestId === pendingPassphraseExport.requestId and fail on mismatch.

May also be worth adding a test to cover inject A --> inject B --> confirm A to verify there's no cross-request mixup.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I believe this isn't an issue, requestId's are generated for each request meaning if we had what you described:

inject A --> Inject B --> Confirm ?

Confirm would actually generate a requestId C which would look like this:

Inject A --> InjectB --> ConfirmC

We also overwrite the "pending" export on each injection so ConfirmC would return the encrypted export for the latest injection (InjectB in this case).

This actually exposes a different bug in my code tho where in the confirm step, I'm attempting to grab the injection's requestId which is wrong, will fix that now!

const responseRequestId =
          requestId || pendingPassphraseExport.requestId;

} else {
// Display only the key
displayKey(key);
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In encryptToPassphrase mode, BUNDLE_INJECTED is emitted immediately after showing the passphrase form, but the real payload is only returned later in PASSPHRASE_ENCRYPTED_BUNDLE. Integrators may treat BUNDLE_INJECTED as completion and trigger overlapping requests. Might be worth introducing a pending status and emitting BUNDLE_INJECTED only after successful passphrase encryption. I'm not super aware of all the places where frames are used so if this isn't really relevant feel free to ignore!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think its fine to keep emitting BUNDLE_INJECTED since that is correct, the bundle is injected into the iframe, its just the user won't receive the result of their passphrase encryption until CONFIRM_PASSPHRASE_.. is sent

@ethankonk ethankonk force-pushed the encrypt-wallet-export branch from e6b7d03 to ace38cb Compare February 26, 2026 22:14
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.

3 participants