Skip to content

added encrypted bundle caching + escrow key injection#115

Open
ethankonk wants to merge 5 commits intomainfrom
ethan/escrow-key-signing
Open

added encrypted bundle caching + escrow key injection#115
ethankonk wants to merge 5 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
     }
  ]
})
  1. Push wallet account export bundles to the iframe's persistent storage

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.storeEncryptedBundle(
    organizationId,
    exportBundle,
    KeyFormat.Solana, // or KeyFormat.Hexadecimal
    account.address // the address of the wallet account that we exported
);
  1. Inject the escrow 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.injectDecryptionKeyBundle(organizationId, exportBundle);

Now all our encrypted bundles are safely stored encrypted in local storage but decrypted in memory! The escrow private key is never kept in persistent storage and must be re-injected whenever the iframe gets destroyed.

Additional helpers events include:

BURN_SESSION

Bulk clears in-memory decrypted bundles (injected escrow key + export bundles), does not wipe local storage

GET_STORED_WALLET_ADDRESSES

Lists all stored wallet addresses in local storage

CLEAR_STORED_BUNDLES

Clears specified addresses if the address parameter is passed, or all addresses in both local storage & in-memory


Sorta open questions:

  • Do we want to set an expiry on the keys stored in local storage? Should we enforce regular "clean outs"?
  • Do we want to be more technical with how we clear in-memory keys? Afaik even though we "clear" them normally, there are still ways to access that data due to how JavaScript's garbage collection works. Not quite sure how this would look like or if this is even something we need to consider

Quick Demo 🎉

https://www.loom.com/share/5b3c9d5427414629b42e81c9278e5df0

PS: the video shows keys that are "ready to sign" from a previous session on a different account. But you wouldn't actually be able to sign with those since that new sub-org I logged into wouldn't have the right decryption escrow key to decrypt the stored bundles with
The above has been fixed!

https://www.loom.com/share/3dca52882847484fa8df2738329fc997

This demo is irrelevant now, will update

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

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