Skip to content
Merged
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
12 changes: 12 additions & 0 deletions auth/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,9 @@ <h2>Message log</h2>
const messageListenerController = new AbortController();
const turnkeyInitController = new AbortController();

// Guard to prevent concurrent channel establishment from multiple senders
let channelEstablished = false;

/**
* DOM Event handlers to power the recovery and auth flows in standalone mode
* Instead of receiving events from the parent page, forms trigger them.
Expand Down Expand Up @@ -1195,6 +1198,15 @@ <h2>Message log</h2>
event.data["type"] == "TURNKEY_INIT_MESSAGE_CHANNEL" &&
event.ports?.[0]
) {
// Synchronously check-and-set the flag before any await. This prevents
// a second concurrent invocation from racing through while the first is
// suspended at an await, which would allow multiple origins to establish
// a channel before turnkeyInitController.abort() is reached.
if (channelEstablished) {
return;
}
channelEstablished = true;

// remove the message event listener that was added in the DOMContentLoaded event
messageListenerController.abort();

Expand Down
3 changes: 3 additions & 0 deletions export-and-sign/dist/bundle.539e9a91965e314c7b7e.js

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

This file was deleted.

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

This file was deleted.

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.9876c027ef7327c209f1.js" integrity="sha384-q6ia441xe0+HGdTE9siYlWrCT1XPwcPEqLxxXOGy6F8K5a+gSv5jWDKcRlRORLr5" 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.539e9a91965e314c7b7e.js" integrity="sha384-HsvCprxVldOT5alrcg2yxF3n4tGWukFOFR2i+BaOXwnjEhTwAKj8/kGrMtZKodcp" crossorigin="anonymous"></script></body></html>
188 changes: 188 additions & 0 deletions export-and-sign/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getKeyNotFoundErrorMessage,
onResetToDefaultEmbeddedKey,
onSetEmbeddedKeyOverride,
initEventHandlers,
} from "./src/event-handlers.js";

jest.mock("@solana/web3.js", () => {
Expand Down Expand Up @@ -1154,3 +1155,190 @@ describe("Embedded Key Override", () => {
});
});
});

// Minimal HTML supplying the DOM elements that initEventHandlers() requires.
const MINIMAL_INIT_HTML = `<!doctype html><html><body>
<input id="embedded-key" />
<button id="inject-key"></button>
<button id="sign-transaction"></button>
<button id="sign-message"></button>
<button id="reset"></button>
</body></html>`;

describe("Channel Establishment Guard", () => {
/**
* These tests verify that the channelEstablished flag prevents a second
* concurrent TURNKEY_INIT_MESSAGE_CHANNEL message from establishing a
* second message channel.
*
* The race is possible because the handler is async: after the synchronous
* preamble runs, execution yields at `await TKHQ.initEmbeddedKey(...)`.
* Without the flag, a second message dispatched before that await resolves
* would pass the outer `if` check and set up a second channel.
*
* With the fix, channelEstablished is set to true synchronously (before
* the first await), so any concurrent invocation returns immediately.
*/

let dom;
let TKHQModule;

beforeEach(async () => {
dom = new JSDOM(MINIMAL_INIT_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;

// initEventHandlers() calls `new AbortController()` and passes the resulting
// signal to window.addEventListener(). JSDOM validates that the signal is an
// instance of *its own* AbortSignal, not Node's native one. Shadow the global
// so that signals created inside initEventHandlers() are recognised by JSDOM.
global.AbortController = dom.window.AbortController;

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

jest.spyOn(TKHQModule, "sendMessageUp").mockImplementation(() => {});
jest
.spyOn(TKHQModule, "setParentFrameMessageChannelPort")
.mockImplementation(() => {});
jest.spyOn(TKHQModule, "initEmbeddedKey").mockResolvedValue(undefined);
jest
.spyOn(TKHQModule, "getEmbeddedKey")
.mockReturnValue({ kty: "EC", crv: "P-256" });
jest
.spyOn(TKHQModule, "p256JWKPrivateToPublic")
.mockResolvedValue(new Uint8Array(65).fill(0x04));
jest.spyOn(TKHQModule, "uint8arrayToHexString").mockReturnValue("aabbccdd");
});

afterEach(() => {
jest.restoreAllMocks();
delete global.window;
delete global.document;
delete global.localStorage;
delete global.crypto;
delete global.AbortController;
});

/**
* Build a MessageEvent that looks like what iframe-stamper sends.
* Uses a plain mock object for the port — JSDOM's MessageEvent accepts it,
* and the handler only needs ports[0] to be truthy and to have an onmessage
* property (TKHQ.setParentFrameMessageChannelPort is mocked anyway).
*/
function makeInitEvent(origin = "https://app.turnkey.com") {
const port = { onmessage: null, postMessage: jest.fn() };
const event = new dom.window.MessageEvent("message", {
data: { type: "TURNKEY_INIT_MESSAGE_CHANNEL" },
ports: [port],
origin,
});
return { event, port };
}

it("processes a single TURNKEY_INIT_MESSAGE_CHANNEL and sends PUBLIC_KEY_READY", async () => {
initEventHandlers(jest.fn());

const { event } = makeInitEvent();
dom.window.dispatchEvent(event);

// Allow the async handler to finish
await new Promise((resolve) => setTimeout(resolve, 0));

expect(TKHQModule.initEmbeddedKey).toHaveBeenCalledTimes(1);
expect(TKHQModule.setParentFrameMessageChannelPort).toHaveBeenCalledTimes(
1
);
expect(TKHQModule.sendMessageUp).toHaveBeenCalledWith(
"PUBLIC_KEY_READY",
"aabbccdd"
);
});

it("ignores a second TURNKEY_INIT_MESSAGE_CHANNEL dispatched while the first is still pending", async () => {
/**
* This is the core race-condition test.
*
* Sequence of events:
* 1. event1 is dispatched. The handler runs synchronously up to
* `await TKHQ.initEmbeddedKey()`, setting channelEstablished = true
* before yielding.
* 2. event2 is dispatched while the first handler is still suspended.
* The handler finds channelEstablished === true and returns immediately.
* 3. The first handler resumes and completes normally.
*
* Without the fix (no channelEstablished flag), both handlers would
* proceed past the if-check and initEmbeddedKey would be called twice.
*/
initEventHandlers(jest.fn());

const { event: event1, port: port1 } = makeInitEvent(
"https://app.turnkey.com"
);
const { event: event2, port: port2 } = makeInitEvent(
"https://malicious.example.com"
);

// Dispatch both before any awaited work completes.
dom.window.dispatchEvent(event1);
dom.window.dispatchEvent(event2);

await new Promise((resolve) => setTimeout(resolve, 0));

// Only the first message should have been handled.
expect(TKHQModule.initEmbeddedKey).toHaveBeenCalledTimes(1);
expect(TKHQModule.setParentFrameMessageChannelPort).toHaveBeenCalledTimes(
1
);
expect(TKHQModule.setParentFrameMessageChannelPort).toHaveBeenCalledWith(
port1
);
expect(
TKHQModule.setParentFrameMessageChannelPort
).not.toHaveBeenCalledWith(port2);
expect(TKHQModule.sendMessageUp).toHaveBeenCalledTimes(1);
expect(TKHQModule.sendMessageUp).toHaveBeenCalledWith(
"PUBLIC_KEY_READY",
"aabbccdd"
);
});

it("ignores a second TURNKEY_INIT_MESSAGE_CHANNEL even after the first has fully completed", async () => {
/**
* Even if the second message arrives after the first handler finishes
* (e.g. a delayed retry from a malicious frame), it must still be rejected.
* The channelEstablished flag persists for the lifetime of the page load.
*/
initEventHandlers(jest.fn());

const { event: event1 } = makeInitEvent("https://app.turnkey.com");
const { event: event2, port: port2 } = makeInitEvent(
"https://malicious.example.com"
);

dom.window.dispatchEvent(event1);
await new Promise((resolve) => setTimeout(resolve, 0));

// First handler is now fully done.
expect(TKHQModule.initEmbeddedKey).toHaveBeenCalledTimes(1);

// Late second message.
dom.window.dispatchEvent(event2);
await new Promise((resolve) => setTimeout(resolve, 0));

// Nothing new should have happened.
expect(TKHQModule.initEmbeddedKey).toHaveBeenCalledTimes(1);
expect(TKHQModule.setParentFrameMessageChannelPort).toHaveBeenCalledTimes(
1
);
expect(
TKHQModule.setParentFrameMessageChannelPort
).not.toHaveBeenCalledWith(port2);
expect(TKHQModule.sendMessageUp).toHaveBeenCalledTimes(1);
});
});
12 changes: 12 additions & 0 deletions export-and-sign/src/event-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,9 @@ export function initEventHandlers(HpkeDecrypt) {
signal: messageListenerController.signal,
});

// Guard to prevent concurrent channel establishment from multiple senders
let channelEstablished = false;

// Handle MessageChannel initialization for iframe communication
window.addEventListener(
"message",
Expand All @@ -769,6 +772,15 @@ export function initEventHandlers(HpkeDecrypt) {
event.data["type"] == "TURNKEY_INIT_MESSAGE_CHANNEL" &&
event.ports?.[0]
) {
// Synchronously check-and-set the flag before any await. This prevents
// a second concurrent invocation from racing through while the first is
// suspended at an await, which would allow multiple origins to establish
// a channel before turnkeyInitController.abort() is reached.
if (channelEstablished) {
return;
}
channelEstablished = true;

// remove the message event listener that was added in the DOMContentLoaded event
messageListenerController.abort();

Expand Down
12 changes: 12 additions & 0 deletions export/index.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,9 @@ <h2>Message log</h2>
const messageListenerController = new AbortController();
const turnkeyInitController = new AbortController();

// Guard to prevent concurrent channel establishment from multiple senders
let channelEstablished = false;

/**
* DOM Event handlers to power the export flow in standalone mode
* Instead of receiving events from the parent page, forms trigger them.
Expand Down Expand Up @@ -1377,6 +1380,15 @@ <h2>Message log</h2>
event.data["type"] == "TURNKEY_INIT_MESSAGE_CHANNEL" &&
event.ports?.[0]
) {
// Synchronously check-and-set the flag before any await. This prevents
// a second concurrent invocation from racing through while the first is
// suspended at an await, which would allow multiple origins to establish
// a channel before turnkeyInitController.abort() is reached.
if (channelEstablished) {
return;
}
channelEstablished = true;

// remove the message event listener that was added in the DOMContentLoaded event
messageListenerController.abort();

Expand Down
Loading
Loading