Skip to content

added encrypted bundle caching + escrow key injection#115

Merged
ethankonk merged 9 commits intomainfrom
ethan/escrow-key-signing
Feb 24, 2026
Merged

added encrypted bundle caching + escrow key injection#115
ethankonk merged 9 commits intomainfrom
ethan/escrow-key-signing

Conversation

@ethankonk
Copy link
Contributor

@ethankonk ethankonk commented Feb 12, 2026

Added the ability to store encrypted wallet account export bundles in the export-and-sign iframe's local storage, and decrypt + sign transactions & messages using an injected "escrow" private key export bundle.

General Flow:

1. Dedicated escrow private key is created with Turnkey

await httpClient?.createPrivateKeys({
  privateKeys: [
     {
      privateKeyName: "<PK_NAME>",
      curve: "CURVE_P256", // MUST BE A P256 KEY
      privateKeyTags: [], // optional
      addressFormats: [] // not needed
     }
  ]
})

2. Inject the escrow key into the iframe to replace the default embedded public key

Nab the iframe's embedded public key to encrypt the escrow bundle too:

const targetPublicKey = await iframeClient.getEmbeddedPublicKey();
if (!targetPublicKey) {
    throw new Error("Failed to retrieve target public key from iframe.");
}

const { exportBundle } =
    (await httpClient?.exportPrivateKey({
    privateKeyId: escrowPrivateKeyId,
    targetPublicKey,
    })) || {};

Inject the export bundle

iframeClient.replaceEmbeddedKey(organizationId, exportBundle);

3. Inject wallet account export bundles into the iframe (now being decrypted by our injected "escrow" key)

The targetPublicKey you encrypt the export bundle to needs to be the public key of the escrow key we created earlier!!

const { exportBundle } =
  (await httpClient?.exportWalletAccount({
    address: account.address,
    targetPublicKey: privateKey.publicKey, // escrow PK public key
  })) || {}; 

Now we push the export bundle:

await iframeClient.injectKeyExportBundle(
    exportBundle,
    organizationId,
    KeyFormat.Solana, // or KeyFormat.Hexadecimal
    account.address // the address of the wallet account that we exported
);

Now all our encrypted bundles are decrypted in memory and ready to sign with! The escrow private key is never kept in persistent storage and must be re-injected whenever the iframe gets destroyed.


Quick Demo 🎉

https://www.loom.com/share/81c1893abf174a32beb8ae3d51086b6f

The SDK side PR that includes this demo can be seen here: tkhq/sdk#1200

Project doc: https://docs.google.com/document/d/16YXWrR75RnRmkM_gURLAzwrEG7FUzK_pF6Z-rQ5g0j8/edit?tab=t.0

@ethankonk ethankonk force-pushed the ethan/escrow-key-signing branch from 5d50e05 to 1fb0969 Compare February 12, 2026 00:53
@ethankonk ethankonk requested review from andrewkmin, Copilot and leeland-turnkey and removed request for leeland-turnkey February 12, 2026 00:54
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

Adds encrypted export-bundle caching in the export-and-sign iframe’s localStorage, plus an in-memory “escrow” decryption key injection flow to decrypt bundles and enable signing without persisting the escrow key.

Changes:

  • Added localStorage helpers for storing/removing encrypted bundles keyed by wallet address.
  • Added new iframe event handlers for storing encrypted bundles, injecting an escrow decryption bundle, listing stored addresses, clearing stored bundles, and burning the session.
  • Added comprehensive Jest coverage for the escrow storage/decryption/signing lifecycle and related helper APIs.

Reviewed changes

Copilot reviewed 4 out of 11 changed files in this pull request and generated 6 comments.

File Description
shared/turnkey-core.js Adds shared localStorage helpers to persist encrypted bundles.
export-and-sign/src/turnkey-core.js Re-exports the new shared encrypted-bundle helper APIs through the iframe TKHQ facade.
export-and-sign/src/event-handlers.js Implements new message handlers for encrypted bundle storage + escrow key injection + session/bundle management.
export-and-sign/index.test.js Adds test coverage for new helper APIs and events (store/decrypt/sign/burn/clear/list).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 234 to 241
function setEncryptedBundle(address, bundleData) {
const bundles = getEncryptedBundles() || {};
bundles[address] = bundleData;
window.localStorage.setItem(
TURNKEY_ENCRYPTED_BUNDLES,
JSON.stringify(bundles)
);
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

Using an untrusted address as an object key allows prototype pollution (e.g., "proto", "constructor", "prototype"), and bundles[address] = ... can mutate the object prototype. Since address ultimately comes from postMessage inputs, this is a concrete risk. Use a Map-like serialization (array of [address, bundleData] entries), or store into an object created via Object.create(null) and explicitly reject dangerous keys before setting/removing. Apply the same protection in removal paths.

Copilot uses AI. Check for mistakes.
@ethankonk ethankonk force-pushed the ethan/escrow-key-signing branch from f6e33fa to 51d9867 Compare February 12, 2026 01:50
@ethankonk ethankonk force-pushed the ethan/escrow-key-signing branch from 3bca095 to e82ee2d Compare February 12, 2026 16:08
Copy link
Contributor

@leeland-turnkey leeland-turnkey left a comment

Choose a reason for hiding this comment

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

Left some comments, but overall this is looking great!

delete inMemoryKeys[address];
} else {
TKHQ.clearAllEncryptedBundles(organizationId);
inMemoryKeys = {};
Copy link
Contributor

Choose a reason for hiding this comment

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

This logic removes all orgs right? I guess this use case wouldn't be hit because most users will only be a part of one org. There could be a chance in the future that there are multiple orgs within one company and this will blow away every org's in-memory key.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is just clearing in-memory keys, not in local storage. There should only be your current org's keys in memory during each "session" so its fine to wipe those completely 😁

format: keyFormat,
expiry: new Date().getTime() + DEFAULT_TTL_MILLISECONDS,
keypair: cachedKeypair, // Cache the keypair for performance
keypair: cachedKeypair,
Copy link
Contributor

Choose a reason for hiding this comment

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

How do we clear by address in my first comment? Since there is no organizationId field, is there a way to do org-aware clearing of in-memory keys?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

onClearStoredBundles has an organizationId you must pass in, if the bundle you are trying to delete is not in your org, it will not allow you to delete it

See here:

/**
 * Removes a single encrypted bundle by address.
 * Only removes the bundle if it belongs to the specified organization.
 * @param {string} address - The wallet address to remove
 * @param {string} organizationId - Only remove if the bundle belongs to this org
 */
function removeEncryptedBundle(address, organizationId) {
  const bundles = getEncryptedBundles();
  if (!bundles || !bundles[address]) return;

  // Only remove if the bundle belongs to the specified organization
  if (bundles[address].organizationId !== organizationId) return;

  delete bundles[address];
  if (Object.keys(bundles).length === 0) {
    window.localStorage.removeItem(TURNKEY_ENCRYPTED_BUNDLES);
  } else {
    window.localStorage.setItem(
      TURNKEY_ENCRYPTED_BUNDLES,
      JSON.stringify(bundles)
    );
  }
}

);

// HPKE-decrypt using the key
const keyBytes = await HpkeDecrypt({
Copy link
Contributor

Choose a reason for hiding this comment

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

We have two variables named keyBytes: https://github.com/tkhq/frames/pull/115/changes#diff-24697156a650f90b0b6154cd01ada56112b8581e71d5c8122705f691a4772943R480

This inner keyBytes variable can work because of block scoping, but it could possibly cause confusion as well. I wonder if a rename would be good here?

);
if (!verified) {
throw new Error(
`failed to verify enclave signature: ${bundleObj.dataSignature}`
Copy link
Contributor

Choose a reason for hiding this comment

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

I like this change over the old way we did it: https://github.com/tkhq/frames/pull/115/changes#diff-24697156a650f90b0b6154cd01ada56112b8581e71d5c8122705f691a4772943L53

We were exposing the entire bundle which probably was not necessary.

Nice work.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copilot pointed that out for me 😅

Copy link
Contributor

Choose a reason for hiding this comment

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

Copilot 🙏

Copy link
Contributor

@leeland-turnkey leeland-turnkey left a comment

Choose a reason for hiding this comment

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

Everything looks good and the rest of my comments are NIT. LGTM

@r-n-o
Copy link
Collaborator

r-n-o commented Feb 14, 2026

This doesn't make a whole lot of sense to me: instead of exporting the wallet directly encrypted to the iframe, we export a private key to which the wallet is encrypted, and persist the wallet.

The result is similar: an export activity has to be performed in both cases, for signing (export private key vs. export wallet). What's the advantage?

(generally, the simpler the crypto the better, so this extra layer of persistence/wrapping makes me nervous)

@ethankonk
Copy link
Contributor Author

You can find their exact use case in the project doc linked here

https://docs.google.com/document/d/16YXWrR75RnRmkM_gURLAzwrEG7FUzK_pF6Z-rQ5g0j8/edit?tab=t.0

@ethankonk
Copy link
Contributor Author

The issue this change is supposed to fix is allowing an important customer to persist a bunch of wallets without having to export them all each session (they need to export many wallets each session). For normal client side signature use you definitely should just use the normal flow (what you described) but in this case, they want tens of export bundles stored in the frames persistent storage for as long as possible

So like you mentioned, to initiate signing, instead of having to export and load a 100 wallets (which takes up to 2 mins), you just need to inject the exported key that can decrypt all the bundles stored in the iframe local storage

@ethankonk ethankonk force-pushed the ethan/escrow-key-signing branch 2 times, most recently from 8cc0280 to 1231999 Compare February 19, 2026 21:07
@ethankonk ethankonk force-pushed the ethan/escrow-key-signing branch from 1231999 to 8081b07 Compare February 19, 2026 21:10
@leeland-turnkey
Copy link
Contributor

Code wise, this looks good to me. I know @r-n-o had some thoughts, so I'll wait for his final approval.

Copy link
Collaborator

@r-n-o r-n-o left a comment

Choose a reason for hiding this comment

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

overall looking great! Some minor naming-related suggestions below


// Injected decryption key -- held in memory only, never persisted.
// When set, decryptBundle uses this P-256 JWK instead of the iframe's embedded key.
let decryptionKey = null;
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe name this "injectedEmbeddedKey" to avoid confusion? Basically we're offering an override here.

(or even embeddedKeyOverride?)

}

if (!TKHQ.verifyEnclaveSignature) {
throw new Error("method not loaded");
Copy link
Collaborator

Choose a reason for hiding this comment

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

"method TKHQ.verifyEnclaveSignature not loaded"?

);
if (!verified) {
throw new Error(
`failed to verify enclave signature: ${bundleObj.dataSignature}`
Copy link
Collaborator

Choose a reason for hiding this comment

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

If we want to log to be useful we should log the public key, the message, and the signature. Either of these could be wrong and lead to a signature verification failure!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Going to omit the message in this case since that would be the encrypted bundle, but added in the enclave quorum pubkey!

// Store in module-level variable (memory only)
decryptionKey = keyJwk;

TKHQ.sendMessageUp("DECRYPTION_KEY_INJECTED", true, requestId);
Copy link
Collaborator

@r-n-o r-n-o Feb 23, 2026

Choose a reason for hiding this comment

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

let's name this "EMBEDDED_KEY_REPLACED" (or EMBEDDED_KEY_OVERRIDE_SET or EMBEDDED_KEY_OVERRIDDEN if you choose to take my naming suggestion for the event/function) for consistency?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Missed this one!

Comment on lines 342 to 351
/**
* Handler for REPLACE_EMBEDDED_KEY events.
* Decrypts a P-256 private key bundle using the iframe's embedded key and
* replaces the embedded key with it for subsequent bundle decryptions.
* @param {string} requestId
* @param {string} organizationId
* @param {string} bundle - v1.0.0 bundle containing the P-256 private key
* @param {Function} HpkeDecrypt
*/
async function onReplaceEmbeddedKey(
Copy link
Collaborator

@r-n-o r-n-o Feb 23, 2026

Choose a reason for hiding this comment

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

Nit: this isn't really replacing, it's setting an override (because you can undo it later!). So "OVERRIDE_EMBEDDED_KEY" or "SET_EMBEDDED_KEY_OVERRIDE" , something like that

@ethankonk ethankonk requested a review from r-n-o February 23, 2026 16:25

// Injected decryption key -- held in memory only, never persisted.
// When set, decryptBundle uses this P-256 JWK instead of the iframe's embedded key.
let injectedDecryptionKey = null;
Copy link
Collaborator

Choose a reason for hiding this comment

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

injectedEmbeddedKey or embeddedKeyOverride (slight preference for embeddedKeyOverride because "injected" and "embedded" in the same name sort of clash!)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh right, brain farted when I made that change 🤦

@ethankonk ethankonk force-pushed the ethan/escrow-key-signing branch from 326c7a5 to 489134b Compare February 24, 2026 18:10
r-n-o
r-n-o previously approved these changes Feb 24, 2026
});
});

describe("Decryption Key Override", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Embedded Key Override

@ethankonk ethankonk merged commit 2a37940 into main Feb 24, 2026
26 checks passed
@ethankonk ethankonk deleted the ethan/escrow-key-signing branch February 24, 2026 19:10
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.

4 participants