diff --git a/export/index.template.html b/export/index.template.html
index e9d2324..32d078e 100644
--- a/export/index.template.html
+++ b/export/index.template.html
@@ -75,6 +75,83 @@
.hidden {
display: none;
}
+ #passphrase-form-div {
+ text-align: center;
+ max-width: 500px;
+ }
+ #passphrase-form-div p {
+ font-size: 0.9em;
+ margin-bottom: 1.5em;
+ }
+ #passphrase-form-div label {
+ display: block;
+ text-align: left;
+ width: auto;
+ word-wrap: none;
+ margin: 0.5em 0 0.25em 0;
+ }
+ #passphrase-form-div input[type="password"] {
+ width: 100%;
+ padding: 0.5em 0;
+ font-size: 1em;
+ border: none;
+ border-bottom: 1px solid #666;
+ box-sizing: border-box;
+ margin-bottom: 0.5em;
+ background-color: transparent;
+ outline: none;
+ }
+ #encrypt-and-export {
+ color: white;
+ width: 100%;
+ font-size: 1em;
+ padding: 0.75em;
+ margin-top: 1em;
+ border-radius: 8px;
+ 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-bottom: 0.5em;
+ text-align: left;
+ }
+ #passphrase-strength {
+ margin-top: 0.5em;
+ text-align: left;
+ }
+ #passphrase-strength-bar {
+ height: 6px;
+ border-radius: 3px;
+ background-color: #e0e0e0;
+ overflow: hidden;
+ margin-bottom: 0.3em;
+ }
+ #passphrase-strength-fill {
+ height: 100%;
+ width: 0%;
+ transition: width 0.3s ease, background-color 0.3s ease;
+ }
+ #passphrase-strength-text {
+ font-size: 0.8em;
+ }
+ .strength-weak {
+ background-color: #e74c3c;
+ }
+ .strength-fair {
+ background-color: #f39c12;
+ }
+ .strength-good {
+ background-color: #3498db;
+ }
+ .strength-strong {
+ background-color: #27ae60;
+ }
@@ -162,6 +239,37 @@
Message log
+
@@ -1191,6 +1299,106 @@ 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.
+ // Normalize to NFC so the same visual passphrase always produces the
+ // same bytes regardless of OS/keyboard Unicode decomposition form.
+ const keyMaterial = await crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(passphrase.normalize("NFC")),
+ "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 (NFC-normalized, matching encryptWithPassphrase).
+ const keyMaterial = await crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(passphrase.normalize("NFC")),
+ "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,
@@ -1221,6 +1429,7 @@ Message log
validateStyles,
getSettings,
setSettings,
+ encryptWithPassphrase,
};
})();
@@ -1235,6 +1444,16 @@ Message log
// controllers to remove event listeners
const messageListenerController = new AbortController();
const turnkeyInitController = new AbortController();
+ const passphraseTextEncoder = new TextEncoder();
+
+ /**
+ * Holds decrypted plaintext bytes while waiting for passphrase encryption.
+ * We keep this data in mutable Uint8Array buffers so we can wipe it.
+ */
+ const pendingPassphraseExport = {
+ plaintextBytes: null,
+ requestId: null,
+ };
/**
* DOM Event handlers to power the export flow in standalone mode
@@ -1285,14 +1504,21 @@ Message log
var messageEventListener = async function (event) {
if (event.data && event.data["type"] == "INJECT_KEY_EXPORT_BUNDLE") {
TKHQ.logMessage(
- `⬇️ Received message ${event.data["type"]}: ${event.data["value"]}, ${event.data["keyFormat"]}, ${event.data["organizationId"]}`
+ `⬇️ Received message ${event.data["type"]}: ${
+ event.data["value"]
+ }, ${event.data["keyFormat"]}, ${
+ event.data["organizationId"]
+ }, encryptToPassphrase=${Boolean(
+ event.data["encryptToPassphrase"]
+ )}`
);
try {
await onInjectKeyBundle(
event.data["value"],
event.data["keyFormat"],
event.data["organizationId"],
- event.data["requestId"]
+ event.data["requestId"],
+ Boolean(event.data["encryptToPassphrase"])
);
} catch (e) {
TKHQ.sendMessageUp("ERROR", e.toString(), event.data["requestId"]);
@@ -1300,18 +1526,31 @@ Message log
}
if (event.data && event.data["type"] == "INJECT_WALLET_EXPORT_BUNDLE") {
TKHQ.logMessage(
- `⬇️ Received message ${event.data["type"]}: ${event.data["value"]}, ${event.data["organizationId"]}`
+ `⬇️ Received message ${event.data["type"]}: ${
+ event.data["value"]
+ }, ${event.data["organizationId"]}, encryptToPassphrase=${Boolean(
+ event.data["encryptToPassphrase"]
+ )}`
);
try {
await onInjectWalletBundle(
event.data["value"],
event.data["organizationId"],
- event.data["requestId"]
+ event.data["requestId"],
+ Boolean(event.data["encryptToPassphrase"])
);
} catch (e) {
TKHQ.sendMessageUp("ERROR", e.toString(), event.data["requestId"]);
}
}
+ if (event.data && event.data["type"] == "CONFIRM_PASSPHRASE_EXPORT") {
+ TKHQ.logMessage(`⬇️ Received message ${event.data["type"]}`);
+ try {
+ await onConfirmPassphraseExport(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"]);
@@ -1349,6 +1588,7 @@ Message log
});
addDOMEventListeners();
+ initPassphraseFormHandlers();
if (!messageListenerController.signal.aborted) {
// If styles are saved in local storage, sanitize and apply them.
@@ -1407,6 +1647,9 @@ Message log
* @param {string} key
*/
function displayKey(key) {
+ hidePassphraseForm();
+ clearPendingPassphraseExport();
+
Array.from(document.body.children).forEach((child) => {
if (child.tagName !== "SCRIPT" && child.id !== "key-div") {
child.style.display = "none";
@@ -1422,9 +1665,9 @@ Message log
textAlign: "left",
};
- // Create a new div with the key material and append the new div to the body
const keyDiv = document.getElementById("key-div");
keyDiv.innerText = key;
+ keyDiv.style.display = "block";
for (let styleKey in style) {
keyDiv.style[styleKey] = style[styleKey];
}
@@ -1432,6 +1675,338 @@ Message log
TKHQ.applySettings(TKHQ.getSettings());
}
+ function wipeUint8Array(buf) {
+ if (!buf) {
+ return;
+ }
+ for (let i = 0; i < buf.length; i++) {
+ buf[i] = 0;
+ }
+ }
+
+ function clearPendingPassphraseExport() {
+ wipeUint8Array(pendingPassphraseExport.plaintextBytes);
+ pendingPassphraseExport.plaintextBytes = null;
+ pendingPassphraseExport.requestId = null;
+ }
+
+ function getSettingsObject() {
+ const settings = TKHQ.getSettings();
+ if (!settings) {
+ return null;
+ }
+ return typeof settings === "string" ? JSON.parse(settings) : settings;
+ }
+
+ function applySettingsToPassphraseForm() {
+ const formDiv = document.getElementById("passphrase-form-div");
+ if (!formDiv) {
+ return;
+ }
+
+ const settingsObj = getSettingsObject();
+ if (!settingsObj || !settingsObj.styles) {
+ return;
+ }
+
+ Object.entries(settingsObj.styles).forEach(([key, value]) => {
+ formDiv.style[key] = value;
+ });
+ }
+
+ function setPassphraseError(message) {
+ const errorMsg = document.getElementById("passphrase-error");
+ if (!errorMsg) {
+ return;
+ }
+ errorMsg.innerText = message;
+ errorMsg.classList.remove("hidden");
+ errorMsg.style.display = "block";
+ errorMsg.style.color = "c0392b";
+ }
+
+ function clearPassphraseError() {
+ const errorMsg = document.getElementById("passphrase-error");
+ if (!errorMsg) {
+ return;
+ }
+ errorMsg.innerText = "";
+ errorMsg.classList.add("hidden");
+ errorMsg.style.display = "none";
+ }
+
+ /**
+ * Evaluate passphrase strength and return score (0-4) with feedback
+ * @param {string} passphrase
+ * @returns {{score: number, label: string, feedback: string}}
+ */
+ function evaluatePassphraseStrength(passphrase) {
+ if (!passphrase) {
+ return { score: 0, label: "", feedback: "" };
+ }
+
+ let score = 0;
+ const checks = {
+ length8: passphrase.length >= 8,
+ length12: passphrase.length >= 12,
+ length16: passphrase.length >= 16,
+ lowercase: /[a-z]/.test(passphrase),
+ uppercase: /[A-Z]/.test(passphrase),
+ numbers: /[0-9]/.test(passphrase),
+ special: /[^a-zA-Z0-9]/.test(passphrase),
+ };
+
+ // Base score from length
+ if (checks.length8) score += 1;
+ if (checks.length12) score += 1;
+ if (checks.length16) score += 1;
+
+ // Character variety
+ const varietyCount = [
+ checks.lowercase,
+ checks.uppercase,
+ checks.numbers,
+ checks.special,
+ ].filter(Boolean).length;
+ if (varietyCount >= 2) score += 1;
+ if (varietyCount >= 3) score += 1;
+ if (varietyCount >= 4) score += 1;
+
+ // Cap at 4
+ score = Math.min(score, 4);
+
+ // Determine label and feedback
+ let label, feedback;
+ if (score <= 1) {
+ label = "Weak";
+ feedback =
+ "Add more characters and mix letters, numbers, and symbols.";
+ } else if (score === 2) {
+ label = "Fair";
+ feedback = "Consider adding more length or character variety.";
+ } else if (score === 3) {
+ label = "Good";
+ feedback = "Good passphrase strength.";
+ } else {
+ label = "Strong";
+ feedback = "Excellent passphrase strength!";
+ }
+
+ return { score, label, feedback };
+ }
+
+ function updatePassphraseStrength(passphrase) {
+ const strengthFill = document.getElementById(
+ "passphrase-strength-fill"
+ );
+ const strengthText = document.getElementById(
+ "passphrase-strength-text"
+ );
+ if (!strengthFill || !strengthText) {
+ return;
+ }
+
+ const { score, label, feedback } =
+ evaluatePassphraseStrength(passphrase);
+ const strengthClasses = [
+ "strength-weak",
+ "strength-fair",
+ "strength-good",
+ "strength-strong",
+ ];
+
+ strengthFill.className = "";
+ if (score === 0) {
+ strengthFill.style.width = "0%";
+ strengthText.textContent = "";
+ return;
+ }
+
+ strengthFill.style.width = `${score * 25}%`;
+ strengthFill.classList.add(strengthClasses[Math.min(score - 1, 3)]);
+ strengthText.textContent = `${label} — ${feedback}`;
+ }
+
+ function hidePassphraseForm() {
+ const formDiv = document.getElementById("passphrase-form-div");
+ const passphraseInput = document.getElementById("export-passphrase");
+ const confirmInput = document.getElementById(
+ "export-passphrase-confirm"
+ );
+
+ if (!formDiv || !passphraseInput || !confirmInput) {
+ return;
+ }
+
+ passphraseInput.value = "";
+ confirmInput.value = "";
+ updatePassphraseStrength("");
+ clearPassphraseError();
+
+ formDiv.classList.add("hidden");
+ formDiv.style.display = "none";
+ }
+
+ /**
+ * Show the passphrase form for deferred client-triggered encryption.
+ * @param {Uint8Array} plaintextBytes
+ * @param {string} requestId
+ */
+ function displayPassphraseForm(plaintextBytes, requestId) {
+ const formDiv = document.getElementById("passphrase-form-div");
+ const description = document.getElementById("passphrase-description");
+ const passphraseInput = document.getElementById("export-passphrase");
+ if (!formDiv || !description || !passphraseInput) {
+ throw new Error("passphrase form is not available in the document");
+ }
+
+ const plaintextBuf =
+ plaintextBytes instanceof Uint8Array
+ ? new Uint8Array(plaintextBytes)
+ : new Uint8Array(plaintextBytes || []);
+ if (plaintextBuf.length === 0) {
+ throw new Error("cannot show passphrase form for empty plaintext");
+ }
+
+ hidePassphraseForm();
+ clearPendingPassphraseExport();
+ pendingPassphraseExport.plaintextBytes = plaintextBuf;
+ pendingPassphraseExport.requestId = requestId;
+
+ description.innerText =
+ "Enter a passphrase to encrypt your key material. You will need this passphrase to decrypt your wallet later.";
+
+ Array.from(document.body.children).forEach((child) => {
+ if (
+ child.tagName !== "SCRIPT" &&
+ child.id !== "passphrase-form-div"
+ ) {
+ child.style.display = "none";
+ }
+ });
+
+ formDiv.classList.remove("hidden");
+ formDiv.style.display = "block";
+ applySettingsToPassphraseForm();
+ passphraseInput.focus();
+ }
+
+ /**
+ * Called by the parent frame to confirm the user is done entering their passphrase.
+ * Validates the passphrase, encrypts the pending plaintext, and sends the
+ * base64-encoded encrypted bundle back to the parent via postMessage.
+ * @param {string} requestId
+ */
+ async function onConfirmPassphraseExport(requestId) {
+ const passphraseInput = document.getElementById("export-passphrase");
+ const confirmInput = document.getElementById(
+ "export-passphrase-confirm"
+ );
+ if (!passphraseInput || !confirmInput) {
+ throw new Error("passphrase form is not available in the document");
+ }
+
+ if (!pendingPassphraseExport.plaintextBytes) {
+ throw new Error("no pending export found for passphrase encryption");
+ }
+
+ const passphrase = passphraseInput.value;
+ const confirmPassphrase = confirmInput.value;
+ if (passphrase.length < 8) {
+ const errorMessage = "Passphrase must be at least 8 characters long.";
+ setPassphraseError(errorMessage);
+ throw new Error(errorMessage);
+ }
+ if (passphrase !== confirmPassphrase) {
+ const errorMessage = "Passphrases do not match.";
+ setPassphraseError(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ clearPassphraseError();
+
+ let plaintextCopy;
+ let encryptedBytes;
+ try {
+ plaintextCopy = new Uint8Array(
+ pendingPassphraseExport.plaintextBytes
+ );
+ encryptedBytes = await TKHQ.encryptWithPassphrase(
+ plaintextCopy,
+ passphrase
+ );
+ const encryptedBase64 = btoa(
+ Array.from(encryptedBytes, (b) => String.fromCharCode(b)).join("")
+ );
+
+ wipeUint8Array(plaintextCopy);
+ wipeUint8Array(encryptedBytes);
+ plaintextCopy = null;
+ encryptedBytes = null;
+
+ hidePassphraseForm();
+ clearPendingPassphraseExport();
+ TKHQ.sendMessageUp(
+ "PASSPHRASE_ENCRYPTED_BUNDLE",
+ encryptedBase64,
+ requestId
+ );
+ } catch (e) {
+ wipeUint8Array(plaintextCopy);
+ wipeUint8Array(encryptedBytes);
+ setPassphraseError("Encryption failed: " + e.toString());
+ throw e;
+ }
+ }
+
+ function initPassphraseFormHandlers() {
+ const formDiv = document.getElementById("passphrase-form-div");
+ const passphraseInput = document.getElementById("export-passphrase");
+ const confirmInput = document.getElementById(
+ "export-passphrase-confirm"
+ );
+ const submitButton = document.getElementById("encrypt-and-export");
+ if (!formDiv || !passphraseInput || !confirmInput || !submitButton) {
+ return;
+ }
+
+ if (formDiv.dataset.handlersInitialized === "true") {
+ return;
+ }
+ formDiv.dataset.handlersInitialized = "true";
+ hidePassphraseForm();
+ const isStandalone = window.parent === window;
+ submitButton.style.display = isStandalone ? "block" : "none";
+
+ passphraseInput.addEventListener("input", () => {
+ updatePassphraseStrength(passphraseInput.value);
+ });
+
+ if (isStandalone) {
+ submitButton.addEventListener("click", async () => {
+ const requestId = pendingPassphraseExport.requestId;
+ try {
+ await onConfirmPassphraseExport(requestId);
+ } catch (e) {
+ TKHQ.sendMessageUp("ERROR", e.toString(), requestId);
+ }
+ });
+
+ passphraseInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ submitButton.click();
+ }
+ });
+ confirmInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ submitButton.click();
+ }
+ });
+ }
+ }
+
/**
* Parse and decrypt the export bundle.
* The `bundle` param is a JSON string of the encapsulated public
@@ -1525,12 +2100,14 @@ Message log
* @param {string} keyFormat
* @param {string} organizationId
* @param {string} requestId
+ * @param {boolean} encryptToPassphrase
*/
async function onInjectKeyBundle(
bundle,
keyFormat,
organizationId,
- requestId
+ requestId,
+ encryptToPassphrase = false
) {
// Decrypt the export bundle
const keyBytes = await decryptBundle(bundle, organizationId);
@@ -1542,24 +2119,40 @@ Message log
var key;
const privateKeyBytes = new Uint8Array(keyBytes);
if (keyFormat === "SOLANA") {
- const privateKeyHex = TKHQ.uint8arrayToHexString(
- privateKeyBytes.subarray(0, 32)
- );
- const publicKeyBytes = TKHQ.getEd25519PublicKey(privateKeyHex);
- key = await TKHQ.encodeKey(
- privateKeyBytes,
- keyFormat,
- publicKeyBytes
- );
+ try {
+ const privateKeyHex = TKHQ.uint8arrayToHexString(
+ privateKeyBytes.subarray(0, 32)
+ );
+ const publicKeyBytes = TKHQ.getEd25519PublicKey(privateKeyHex);
+ key = await TKHQ.encodeKey(
+ privateKeyBytes,
+ keyFormat,
+ publicKeyBytes
+ );
+ } finally {
+ wipeUint8Array(privateKeyBytes);
+ }
} else {
- key = await TKHQ.encodeKey(privateKeyBytes, keyFormat);
+ try {
+ key = await TKHQ.encodeKey(privateKeyBytes, keyFormat);
+ } finally {
+ wipeUint8Array(privateKeyBytes);
+ }
}
- // Display only the key
- displayKey(key);
+ if (encryptToPassphrase) {
+ const keyBytesToEncrypt = passphraseTextEncoder.encode(key);
+ displayPassphraseForm(keyBytesToEncrypt, requestId);
+ wipeUint8Array(keyBytesToEncrypt);
+ } else {
+ // Display only the key
+ displayKey(key);
+ }
// Send up BUNDLE_INJECTED message
TKHQ.sendMessageUp("BUNDLE_INJECTED", true, requestId);
+
+ key = "";
}
/**
@@ -1567,8 +2160,14 @@ Message log
* @param {string} bundle
* @param {string} organizationId
* @param {string} requestId
+ * @param {boolean} encryptToPassphrase
*/
- async function onInjectWalletBundle(bundle, organizationId, requestId) {
+ async function onInjectWalletBundle(
+ bundle,
+ organizationId,
+ requestId,
+ encryptToPassphrase = false
+ ) {
// Decrypt the export bundle
const walletBytes = await decryptBundle(bundle, organizationId);
@@ -1576,13 +2175,28 @@ Message log
TKHQ.onResetEmbeddedKey();
// Parse the decrypted wallet bytes
- const wallet = TKHQ.encodeWallet(new Uint8Array(walletBytes));
+ const walletBytesUint8 = new Uint8Array(walletBytes);
+ let wallet;
+ try {
+ wallet = TKHQ.encodeWallet(walletBytesUint8);
+ } finally {
+ wipeUint8Array(walletBytesUint8);
+ }
- // Display only the wallet's mnemonic
- displayKey(wallet.mnemonic);
+ if (encryptToPassphrase) {
+ const mnemonicBytes = passphraseTextEncoder.encode(wallet.mnemonic);
+ displayPassphraseForm(mnemonicBytes, requestId);
+ wipeUint8Array(mnemonicBytes);
+ } else {
+ // Display only the wallet's mnemonic
+ displayKey(wallet.mnemonic);
+ }
// Send up BUNDLE_INJECTED message
TKHQ.sendMessageUp("BUNDLE_INJECTED", true, requestId);
+
+ wallet.mnemonic = "";
+ wallet.passphrase = "";
}
/**
@@ -1600,6 +2214,11 @@ Message log
// Persist in local storage
TKHQ.setSettings(validSettings);
+ const passphraseForm = document.getElementById("passphrase-form-div");
+ if (passphraseForm && !passphraseForm.classList.contains("hidden")) {
+ applySettingsToPassphraseForm();
+ }
+
// Send up SETTINGS_APPLIED message
TKHQ.sendMessageUp("SETTINGS_APPLIED", true, requestId);
}
diff --git a/export/index.test.js b/export/index.test.js
index 79648fe..d8e3c3f 100644
--- a/export/index.test.js
+++ b/export/index.test.js
@@ -8,6 +8,41 @@ const html = fs
.readFileSync(path.resolve(__dirname, "./index.template.html"), "utf8")
.replace("${TURNKEY_SIGNER_ENVIRONMENT}", "prod");
+/**
+ * Mirror of the iframe's encryptWithPassphrase, used to verify round-trips in tests.
+ * Kept here rather than exported from TKHQ to avoid exposing decrypt on the global
+ * in production.
+ */
+async function decryptWithPassphrase(encryptedBuf, passphrase) {
+ const salt = encryptedBuf.slice(0, 16);
+ const iv = encryptedBuf.slice(16, 28);
+ const ciphertext = encryptedBuf.slice(28);
+
+ const keyMaterial = await crypto.webcrypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(passphrase.normalize("NFC")),
+ "PBKDF2",
+ false,
+ ["deriveBits", "deriveKey"]
+ );
+
+ const aesKey = await crypto.webcrypto.subtle.deriveKey(
+ { name: "PBKDF2", salt, iterations: 600000, hash: "SHA-256" },
+ keyMaterial,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["decrypt"]
+ );
+
+ const decrypted = await crypto.webcrypto.subtle.decrypt(
+ { name: "AES-GCM", iv },
+ aesKey,
+ ciphertext
+ );
+
+ return new Uint8Array(decrypted);
+}
+
let dom;
let TKHQ;
@@ -446,4 +481,406 @@ 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 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(
+ 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 decryptWithPassphrase(encrypted1, passphrase);
+ const decrypted2 = await 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(
+ Array.from(encrypted, (b) => String.fromCharCode(b)).join("")
+ );
+ 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 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("form");
+ 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 = "submit";
+ submitButton.id = "encrypt-and-export";
+ formDiv.appendChild(submitButton);
+
+ document.body.appendChild(formDiv);
+
+ return { formDiv, passphraseInput, confirmInput, errorMsg, submitButton };
+ }
+
+ // Helper to submit form (triggers validation)
+ function submitForm(elements) {
+ const event = new dom.window.Event("submit", {
+ bubbles: true,
+ cancelable: true,
+ });
+ elements.formDiv.dispatchEvent(event);
+ }
+
+ // Helper to create submit handler that mimics displayPassphraseForm logic
+ function addValidationHandler(elements, mnemonic, onSuccess) {
+ const { formDiv, passphraseInput, confirmInput, errorMsg } = elements;
+
+ formDiv.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ 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("contains hidden static passphrase form elements", () => {
+ const form = document.getElementById("passphrase-form-div");
+ expect(form).toBeInTheDocument();
+ expect(form.classList.contains("hidden")).toBe(true);
+ expect(
+ document.getElementById("passphrase-description")
+ ).toBeInTheDocument();
+ expect(document.getElementById("export-passphrase")).toBeInTheDocument();
+ expect(
+ document.getElementById("export-passphrase-confirm")
+ ).toBeInTheDocument();
+ expect(document.getElementById("encrypt-and-export")).toBeInTheDocument();
+ });
+
+ 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";
+
+ // Submit form
+ submitForm(elements);
+
+ // 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";
+
+ submitForm(elements);
+
+ 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";
+
+ submitForm(elements);
+
+ // 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";
+
+ submitForm(elements);
+
+ 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";
+
+ submitForm(elements);
+
+ // 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";
+ submitForm(elements);
+ expect(elements.errorMsg.style.display).toBe("block");
+
+ // Now enter valid passphrases
+ elements.passphraseInput.value = "validpassword123";
+ elements.confirmInput.value = "validpassword123";
+ submitForm(elements);
+
+ // 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 = "";
+
+ submitForm(elements);
+
+ // 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;
+
+ submitForm(elements);
+
+ 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;
+
+ submitForm(elements);
+
+ 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";
+
+ submitForm(elements);
+
+ expect(elements.errorMsg.style.display).toBe("block");
+ expect(elements.errorMsg.innerText).toBe("Passphrases do not match.");
+ });
});