Skip to content
Open
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
3 changes: 0 additions & 3 deletions export-and-sign/dist/bundle.2ddbc9eacea08f1f698a.js

This file was deleted.

1 change: 0 additions & 1 deletion export-and-sign/dist/bundle.2ddbc9eacea08f1f698a.js.map

This file was deleted.

3 changes: 3 additions & 0 deletions export-and-sign/dist/bundle.dd09773f50e375d9ca7e.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions export-and-sign/dist/bundle.dd09773f50e375d9ca7e.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion export-and-sign/dist/index.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<!doctype html><html class="no-js"><head><link rel="icon" type="image/svg+xml" href="./favicon.svg"/><meta charset="utf-8"/><title>Turnkey Export</title><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="turnkey-signer-environment" content="__TURNKEY_SIGNER_ENVIRONMENT__"/><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; base-uri 'self'; object-src 'none'; form-action 'none'"><link href="/styles.e084a69a94c0575bc6ba.css" rel="stylesheet" integrity="sha384-uIrxQTbBoDAwjgotQ+GUHgbxFM2iajB5QKNa4WuL9wn/Ou+2383e3dM2FCWOAq9m" crossorigin="anonymous"></head><body><h2>Export Key Material</h2><p><em>This public key will be sent along with a private key ID or wallet ID inside of a new <code>EXPORT_PRIVATE_KEY</code> or <code>EXPORT_WALLET</code> activity</em></p><form><label>Embedded key</label> <input name="embedded-key" id="embedded-key" disabled="disabled"/> <button id="reset">Reset Key</button></form><br/><br/><br/><h2>Inject Key Export Bundle</h2><p><em>The export bundle comes from the parent page and is composed of a public key and an encrypted payload. The payload is encrypted to this document's embedded key (stored in local storage and displayed above). The scheme relies on <a target="_blank" href="https://datatracker.ietf.org/doc/rfc9180/">HPKE (RFC 9180)</a></em>.</p><form><label>Bundle</label> <input name="key-export-bundle" id="key-export-bundle"/> <button id="inject-key">Inject Bundle</button><br/><label>Key Format</label> <select id="key-export-format" name="key-export-format"><option value="HEXADECIMAL">Hexadecimal (Default)</option><option value="SOLANA">Solana</option></select><br/><label>Organization Id</label> <input name="key-organization-id" id="key-organization-id"/></form><br/><br/><h2>Inject Wallet Export Bundle</h2><p><em>The export bundle comes from the parent page and is composed of a public key and an encrypted payload. The payload is encrypted to this document's embedded key (stored in local storage and displayed above). The scheme relies on <a target="_blank" href="https://datatracker.ietf.org/doc/rfc9180/">HPKE (RFC 9180)</a></em>.</p><form><label>Bundle</label> <input name="wallet-export-bundle" id="wallet-export-bundle"/> <button id="inject-wallet">Inject Bundle</button><br/><label>Organization Id</label> <input name="wallet-organization-id" id="wallet-organization-id"/></form><br/><br/><h2>Sign Transaction</h2><p><em>Input a serialized transaction to sign.</em></p><form><label>Transaction</label> <input name="transaction-to-sign" id="transaction-to-sign"/> <button id="sign-transaction">Sign</button></form><br/><br/><h2>Sign Message</h2><p><em>Input a serialized message to sign.</em></p><form><label>Message</label> <input name="message-to-sign" id="message-to-sign"/> <button id="sign-message">Sign</button></form><br/><br/><h2>Message log</h2><p><em>Below we display a log of the messages sent / received. The forms above send messages, and the code communicates results by sending events via the <code>postMessage</code> API.</em></p><div id="message-log"></div><div id="key-div"></div><script defer="defer" src="/bundle.921b01a774677f8e2da8.js" integrity="sha384-P/yUGeA+YjATjB94JS/FcpAKrqBRW/oFjpTPQJAEZMy2zDCV+2mfOqsTbuxZkCcy" crossorigin="anonymous"></script><script defer="defer" src="/bundle.2ddbc9eacea08f1f698a.js" integrity="sha384-4W+bjItMNn3ZCQ3Z3NCMibWcCV0Nh645/TwMaRfW+huAfSjdL8KEipAgMQqxpIeZ" crossorigin="anonymous"></script></body></html>
<!doctype html><html class="no-js"><head><link rel="icon" type="image/svg+xml" href="./favicon.svg"/><meta charset="utf-8"/><title>Turnkey Export</title><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="turnkey-signer-environment" content="__TURNKEY_SIGNER_ENVIRONMENT__"/><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; base-uri 'self'; object-src 'none'; form-action 'none'"><link href="/styles.e084a69a94c0575bc6ba.css" rel="stylesheet" integrity="sha384-uIrxQTbBoDAwjgotQ+GUHgbxFM2iajB5QKNa4WuL9wn/Ou+2383e3dM2FCWOAq9m" crossorigin="anonymous"></head><body><h2>Export Key Material</h2><p><em>This public key will be sent along with a private key ID or wallet ID inside of a new <code>EXPORT_PRIVATE_KEY</code> or <code>EXPORT_WALLET</code> activity</em></p><form><label>Embedded key</label> <input name="embedded-key" id="embedded-key" disabled="disabled"/> <button id="reset">Reset Key</button></form><br/><br/><br/><h2>Inject Key Export Bundle</h2><p><em>The export bundle comes from the parent page and is composed of a public key and an encrypted payload. The payload is encrypted to this document's embedded key (stored in local storage and displayed above). The scheme relies on <a target="_blank" href="https://datatracker.ietf.org/doc/rfc9180/">HPKE (RFC 9180)</a></em>.</p><form><label>Bundle</label> <input name="key-export-bundle" id="key-export-bundle"/> <button id="inject-key">Inject Bundle</button><br/><label>Key Format</label> <select id="key-export-format" name="key-export-format"><option value="HEXADECIMAL">Hexadecimal (Default)</option><option value="SOLANA">Solana</option></select><br/><label>Organization Id</label> <input name="key-organization-id" id="key-organization-id"/></form><br/><br/><h2>Inject Wallet Export Bundle</h2><p><em>The export bundle comes from the parent page and is composed of a public key and an encrypted payload. The payload is encrypted to this document's embedded key (stored in local storage and displayed above). The scheme relies on <a target="_blank" href="https://datatracker.ietf.org/doc/rfc9180/">HPKE (RFC 9180)</a></em>.</p><form><label>Bundle</label> <input name="wallet-export-bundle" id="wallet-export-bundle"/> <button id="inject-wallet">Inject Bundle</button><br/><label>Organization Id</label> <input name="wallet-organization-id" id="wallet-organization-id"/></form><br/><br/><h2>Sign Transaction</h2><p><em>Input a serialized transaction to sign.</em></p><form><label>Transaction</label> <input name="transaction-to-sign" id="transaction-to-sign"/> <button id="sign-transaction">Sign</button></form><br/><br/><h2>Sign Message</h2><p><em>Input a serialized message to sign.</em></p><form><label>Message</label> <input name="message-to-sign" id="message-to-sign"/> <button id="sign-message">Sign</button></form><br/><br/><h2>Message log</h2><p><em>Below we display a log of the messages sent / received. The forms above send messages, and the code communicates results by sending events via the <code>postMessage</code> API.</em></p><div id="message-log"></div><div id="key-div"></div><script defer="defer" src="/bundle.921b01a774677f8e2da8.js" integrity="sha384-P/yUGeA+YjATjB94JS/FcpAKrqBRW/oFjpTPQJAEZMy2zDCV+2mfOqsTbuxZkCcy" crossorigin="anonymous"></script><script defer="defer" src="/bundle.dd09773f50e375d9ca7e.js" integrity="sha384-Q0AO9F8Gx4LruurZTp/JycvxddebbJAr9dzaHbJbtSfeUQKugTpr5HUWR7NaRAKQ" crossorigin="anonymous"></script></body></html>
306 changes: 306 additions & 0 deletions export-and-sign/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
onInjectKeyBundle,
onSignTransaction,
getKeyNotFoundErrorMessage,
onReplaceEmbeddedKey,
onResetToDefaultEmbeddedKey,
} from "./src/event-handlers.js";

jest.mock("@solana/web3.js", () => {
Expand Down Expand Up @@ -852,3 +854,307 @@ describe("Event Handler Expiration Flow", () => {
}
});
});

describe("Decryption Key Override", () => {
const requestId = "test-request-id";
const serializedTransaction = JSON.stringify({
type: "SOLANA",
transaction: "00",
});

let dom;
let TKHQ;
let sendMessageSpy;

// Mock raw 32-byte P-256 private key (decryption key)
// This is what Turnkey exports after HPKE decryption - raw key bytes, not a JWK.
const mockDecryptionKeyBytes = new Uint8Array(32).fill(42);

function buildBundle(organizationId = "org-test") {
const signedData = {
organizationId,
encappedPublic: "aa",
ciphertext: "bb",
};

const signedDataHex = Buffer.from(
JSON.stringify(signedData),
"utf8"
).toString("hex");

return JSON.stringify({
version: "v1.0.0",
data: signedDataHex,
dataSignature: "30440220773382ac",
enclaveQuorumPublic: "04e479640d6d34",
});
}

beforeEach(async () => {
jest.useFakeTimers().setSystemTime(new Date("2025-01-01T00:00:00Z"));

dom = new JSDOM(
`<!doctype html><html><body><div id="key-div"></div><input id="embedded-key" /></body></html>`,
{
url: "http://localhost",
}
);

global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
global.crypto = crypto.webcrypto;

const module = await import("./src/turnkey-core.js");
TKHQ = module.TKHQ;
dom.window.TKHQ = TKHQ;

sendMessageSpy = jest
.spyOn(TKHQ, "sendMessageUp")
.mockImplementation(() => {});

jest.spyOn(TKHQ, "verifyEnclaveSignature").mockResolvedValue(true);
TKHQ.setEmbeddedKey({ foo: "bar" });
jest.spyOn(TKHQ, "onResetEmbeddedKey").mockImplementation(() => {});
jest.spyOn(TKHQ, "uint8arrayFromHexString").mockImplementation((hex) => {
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) {
throw new Error("cannot create uint8array from invalid hex string");
}
return new Uint8Array(Buffer.from(hex, "hex"));
});
jest
.spyOn(TKHQ, "uint8arrayToHexString")
.mockImplementation((bytes) => Buffer.from(bytes).toString("hex"));
jest
.spyOn(TKHQ, "getEd25519PublicKey")
.mockReturnValue(new Uint8Array(32).fill(3));
jest
.spyOn(TKHQ, "encodeKey")
.mockImplementation(async (keyBytes, format) => {
if (format === "SOLANA") {
return "2P3qgS5A18gGmZJmYHNxYrDYPyfm6S3dJgs8tPW6ki6i2o4yx7K8r5N8CF7JpEtQiW8mx1kSktpgyDG1xuWNzfsM";
}
return "encoded-key-material";
});
jest
.spyOn(TKHQ, "parsePrivateKey")
.mockReturnValue(new Uint8Array(64).fill(5));
});

afterEach(() => {
// Reset module-level decryptionKey
onResetToDefaultEmbeddedKey("cleanup");
jest.useRealTimers();
jest.restoreAllMocks();
delete global.window;
delete global.document;
delete global.localStorage;
delete global.crypto;
});

describe("REPLACE_EMBEDDED_KEY handler", () => {
it("decrypts and stores the decryption key", async () => {
const HpkeDecryptMock = jest
.fn()
.mockResolvedValue(mockDecryptionKeyBytes);

await onReplaceEmbeddedKey(
requestId,
"org-test",
buildBundle(),
HpkeDecryptMock
);

expect(HpkeDecryptMock).toHaveBeenCalledTimes(1);
expect(sendMessageSpy).toHaveBeenCalledWith(
"DECRYPTION_KEY_INJECTED",
true,
requestId
);
});

it("rejects invalid decryption key length", async () => {
const HpkeDecryptMock = jest
.fn()
.mockResolvedValue(new Uint8Array(16).fill(1));

await expect(
onReplaceEmbeddedKey(
requestId,
"org-test",
buildBundle(),
HpkeDecryptMock
)
).rejects.toThrow("invalid decryption key length");
});

it("uses injected key for subsequent bundle decryptions", async () => {
// 1. Replace embedded key with decryption key
let callCount = 0;
const HpkeDecryptMock = jest.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
// First call: decrypting the decryption key bundle itself (uses embedded key)
return Promise.resolve(mockDecryptionKeyBytes);
}
// Subsequent calls: decrypting wallet bundles (should use the injected key)
return Promise.resolve(new Uint8Array(64).fill(9));
});

await onReplaceEmbeddedKey(
requestId,
"org-test",
buildBundle(),
HpkeDecryptMock
);

// 2. Inject a wallet bundle normally — decryptBundle should use the injected key
await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"SOLANA",
"wallet1",
HpkeDecryptMock
);

// Verify the second HpkeDecrypt call used the injected key (not the embedded key)
const secondCall = HpkeDecryptMock.mock.calls[1][0];
expect(secondCall.receiverPrivJwk).not.toEqual({ foo: "bar" }); // not the embedded key
expect(secondCall.receiverPrivJwk.kty).toBe("EC");
expect(secondCall.receiverPrivJwk.crv).toBe("P-256");

// 3. Verify key is usable by signing
await onSignTransaction(requestId, serializedTransaction, "wallet1");
expect(sendMessageSpy).toHaveBeenCalledWith(
"TRANSACTION_SIGNED",
expect.any(String),
requestId
);
});
});

describe("RESET_TO_DEFAULT_EMBEDDED_KEY handler", () => {
it("clears the injected decryption key", async () => {
// 1. Replace embedded key
const HpkeDecryptMock = jest
.fn()
.mockResolvedValue(mockDecryptionKeyBytes);

await onReplaceEmbeddedKey(
requestId,
"org-test",
buildBundle(),
HpkeDecryptMock
);

// 2. Reset to default
onResetToDefaultEmbeddedKey(requestId);

expect(sendMessageSpy).toHaveBeenCalledWith(
"RESET_TO_DEFAULT_EMBEDDED_KEY",
true,
requestId
);

// 3. Next bundle decryption should use the embedded key again
HpkeDecryptMock.mockResolvedValue(new Uint8Array(64).fill(9));
await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"SOLANA",
"wallet1",
HpkeDecryptMock
);

const lastCall =
HpkeDecryptMock.mock.calls[HpkeDecryptMock.mock.calls.length - 1][0];
expect(lastCall.receiverPrivJwk).toEqual({ foo: "bar" }); // back to the embedded key
});
});

describe("Full Lifecycle", () => {
it("replace key -> inject bundles -> sign -> reset -> inject uses embedded key", async () => {
// 1. Replace embedded key with decryption key
let callCount = 0;
const HpkeDecryptMock = jest.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve(mockDecryptionKeyBytes);
}
return Promise.resolve(new Uint8Array(64).fill(9));
});

await onReplaceEmbeddedKey(
requestId,
"org-test",
buildBundle(),
HpkeDecryptMock
);

expect(sendMessageSpy).toHaveBeenCalledWith(
"DECRYPTION_KEY_INJECTED",
true,
requestId
);

// 2. Inject wallet bundles normally (decrypted with injected key)
await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"SOLANA",
"wallet-a",
HpkeDecryptMock
);
await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"SOLANA",
"wallet-b",
HpkeDecryptMock
);

// 3. Sign transactions
await onSignTransaction(requestId, serializedTransaction, "wallet-a");
expect(sendMessageSpy).toHaveBeenCalledWith(
"TRANSACTION_SIGNED",
expect.any(String),
requestId
);

await onSignTransaction(requestId, serializedTransaction, "wallet-b");
expect(sendMessageSpy).toHaveBeenCalledWith(
"TRANSACTION_SIGNED",
expect.any(String),
requestId
);

// 4. Reset to default embedded key
onResetToDefaultEmbeddedKey(requestId);
expect(sendMessageSpy).toHaveBeenCalledWith(
"RESET_TO_DEFAULT_EMBEDDED_KEY",
true,
requestId
);

// 5. Next inject uses embedded key (not the injected one)
await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"SOLANA",
"wallet-c",
HpkeDecryptMock
);

const lastCall =
HpkeDecryptMock.mock.calls[HpkeDecryptMock.mock.calls.length - 1][0];
expect(lastCall.receiverPrivJwk).toEqual({ foo: "bar" }); // embedded key
});
});
});
Loading
Loading