Skip to content

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.
bundleObj.data
);
if (!verified) {
throw new Error(`failed to verify enclave signature: ${bundle}`);
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.

This error message embeds the full bundle payload (including ciphertext/encappedPublic). If this propagates to sendMessageUp("ERROR", ...) and/or logs, it can leak sensitive encrypted material and bloat logs. Prefer a redacted error (e.g., include version + organizationId + maybe a short hash) rather than interpolating the complete bundle string.

Suggested change
throw new Error(`failed to verify enclave signature: ${bundle}`);
throw new Error(
`failed to verify enclave signature (version=${bundleObj.version}, organizationId=${organizationId ?? "unknown"})`
);

Copilot uses AI. Check for mistakes.
Comment on lines +416 to +426
// PKCS8 DER prefix for a P-256 private key (without optional public key field)
// SEQUENCE {
// INTEGER 0 (version)
// SEQUENCE { OID ecPublicKey, OID P-256 }
// OCTET STRING { SEQUENCE { INTEGER 1, OCTET STRING(32) <key> } }
// }
const pkcs8Prefix = new Uint8Array([
0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20,
]);
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.

The PKCS#8 prefix is a hard-coded DER blob with multiple magic constants (e.g., 0x41 / 0x27 / 0x25). This is brittle and hard to audit. Consider adding an authoritative reference link (RFC / ASN.1 structure source) and/or deriving these lengths programmatically to reduce the chance of subtle format errors if the structure ever needs to change.

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
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.

1 participant