Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
325 changes: 325 additions & 0 deletions export/index.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
</style>
</head>

Expand Down Expand Up @@ -831,6 +889,104 @@ <h2>Message log</h2>
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<Uint8Array>} - 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<Uint8Array>} - 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,
Expand Down Expand Up @@ -858,6 +1014,8 @@ <h2>Message log</h2>
validateStyles,
getSettings,
setSettings,
encryptWithPassphrase,
decryptWithPassphrase,
};
})();
</script>
Expand Down Expand Up @@ -949,6 +1107,20 @@ <h2>Message log</h2>
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"]);
Expand Down Expand Up @@ -1069,6 +1241,135 @@ <h2>Message log</h2>
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;
}
Comment on lines +1322 to +1328
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.

// 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)
);
Comment on lines +1352 to +1355
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.


// Send message up
TKHQ.sendMessageUp(
"ENCRYPTED_WALLET_EXPORT",
encryptedBase64,
requestId
);
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.

After encryption succeeds and the message is sent to the parent frame, the sensitive mnemonic remains in memory within the closure. Consider clearing the mnemonic variable and the form inputs after successful encryption to minimize the window of exposure for this sensitive data in memory.

For example, setting the passphrase input values to empty strings and potentially overwriting the mnemonic variable.

Suggested change
);
);
// Clear sensitive data from memory after successful encryption
passphraseInput.value = "";
confirmInput.value = "";
for (let i = 0; i < mnemonicBytes.length; i++) {
mnemonicBytes[i] = 0;
}
for (let i = 0; i < encryptedBytes.length; i++) {
encryptedBytes[i] = 0;
}
mnemonic = "";

Copilot uses AI. Check for mistakes.

// Keep button disabled after success (operation complete)
} 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.
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
Expand Down Expand Up @@ -1222,6 +1523,30 @@ <h2>Message log</h2>
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".
Expand Down
Loading
Loading