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
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.github
.editorconfig
**/node_modules
Original file line number Diff line number Diff line change
@@ -1 +1 @@
(self.webpackChunkexport_and_sign=self.webpackChunkexport_and_sign||[]).push([[291],{291:()=>{}}]);
(self.webpackChunkexport_and_sign=self.webpackChunkexport_and_sign||[]).push([[825],{4825:()=>{}}]);
3 changes: 3 additions & 0 deletions export-and-sign/dist/bundle.52d2885ae469455328a8.js

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

This file was deleted.

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

This file was deleted.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

This file was deleted.

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

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1 +1 @@
(self.webpackChunkexport_and_sign=self.webpackChunkexport_and_sign||[]).push([[825],{825:()=>{}}]);
(self.webpackChunkexport_and_sign=self.webpackChunkexport_and_sign||[]).push([[291],{6291:()=>{}}]);
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.539e9a91965e314c7b7e.js" integrity="sha384-HsvCprxVldOT5alrcg2yxF3n4tGWukFOFR2i+BaOXwnjEhTwAKj8/kGrMtZKodcp" 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.52d2885ae469455328a8.js" integrity="sha384-3wMJzNhGL5qge63ZZfnMLctOqnolhcZRN1e+0Ixuoke9uBc+DSfGKlaJxBYlGBwJ" crossorigin="anonymous"></script><script defer="defer" src="/bundle.6f3ad536a859e78bdbd5.js" integrity="sha384-guxJoU9JiXhCwn/DtR8ORa8i1VvRKTAWXtGoZ2PbFX+9V8xlljd6o+Ad6zYklyJn" crossorigin="anonymous"></script></body></html>
195 changes: 195 additions & 0 deletions export-and-sign/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ import {
DEFAULT_TTL_MILLISECONDS,
onInjectKeyBundle,
onSignTransaction,
onSignMessage,
getKeyNotFoundErrorMessage,
onResetToDefaultEmbeddedKey,
onSetEmbeddedKeyOverride,
initEventHandlers,
} from "./src/event-handlers.js";
import {
serializeTransaction,
recoverTransactionAddress,
recoverMessageAddress,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";

jest.mock("@solana/web3.js", () => {
const mockKeypair = {
Expand Down Expand Up @@ -856,6 +863,194 @@ describe("Event Handler Expiration Flow", () => {
});
});

describe("EVM Signing", () => {
const requestId = "test-request-id";

// Well-known test key (Hardhat account #1). privateKeyToAccount(...) yields
// 0x70997970C51812dc3A010C7d01b50e0d17dc79C8.
const EVM_PRIVATE_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const evmAccount = privateKeyToAccount(EVM_PRIVATE_KEY);

let dom;
let TKHQ;
let sendMessageSpy;

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" });

// Mirror the real impl (strip 0x) so both the bundle-data decode and the
// HEXADECIMAL key load work.
jest.spyOn(TKHQ, "uint8arrayFromHexString").mockImplementation((hex) => {
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
return new Uint8Array(Buffer.from(clean, "hex"));
});
jest
.spyOn(TKHQ, "uint8arrayToHexString")
.mockImplementation((bytes) => Buffer.from(bytes).toString("hex"));
jest
.spyOn(TKHQ, "parsePrivateKey")
.mockReturnValue(new Uint8Array(64).fill(5));

// encodeKey normally returns the encoded private key string that becomes
// key.privateKey. Return our known EVM key so the iframe's
// privateKeyToAccount(...) reconstructs the matching signer.
jest.spyOn(TKHQ, "encodeKey").mockResolvedValue(EVM_PRIVATE_KEY);
});

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

async function injectEvmKey(address) {
const HpkeDecryptMock = jest
.fn()
.mockResolvedValue(new Uint8Array(32).fill(9));

await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"HEXADECIMAL",
address,
HpkeDecryptMock
);
}

it("signs an EVM transaction into a recoverable, broadcast-ready tx", async () => {
await injectEvmKey(undefined);

// Caller builds + serializes the UNSIGNED tx with any library, mirroring
// the Solana contract (serialized hex in, serialized hex out).
const serializedUnsigned = serializeTransaction({
to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
value: 1000000000000000n,
chainId: 1,
nonce: 5,
gas: 21000n,
maxFeePerGas: 30000000000n,
maxPriorityFeePerGas: 1000000000n,
});

await onSignTransaction(
requestId,
JSON.stringify({ type: "ETHEREUM", transaction: serializedUnsigned }),
undefined
);

const signedCall = sendMessageSpy.mock.calls.find(
(c) => c[0] === "TRANSACTION_SIGNED"
);
expect(signedCall).toBeDefined();

const signedTx = signedCall[1];
expect(signedTx.startsWith("0x")).toBe(true);

// The signed tx must recover to the injected key's address.
const recovered = await recoverTransactionAddress({
serializedTransaction: signedTx,
});
expect(recovered).toBe(evmAccount.address);
});

it("signs an EVM message (EIP-191 personal_sign)", async () => {
await injectEvmKey(undefined);

const message = "Hello Turnkey!";
await onSignMessage(
requestId,
JSON.stringify({ type: "ETHEREUM", message }),
undefined
);

const signedCall = sendMessageSpy.mock.calls.find(
(c) => c[0] === "MESSAGE_SIGNED"
);
expect(signedCall).toBeDefined();

const signature = signedCall[1];
expect(signature.startsWith("0x")).toBe(true);

const recovered = await recoverMessageAddress({ message, signature });
expect(recovered).toBe(evmAccount.address);
});

it("rejects an unsupported transaction type", async () => {
await injectEvmKey(undefined);

await expect(
onSignTransaction(
requestId,
JSON.stringify({ type: "DOGECOIN", transaction: "0x00" }),
undefined
)
).rejects.toThrow("unsupported transaction type");
});

it("sends an ERROR for an unsupported message type", async () => {
await injectEvmKey(undefined);

await onSignMessage(
requestId,
JSON.stringify({ type: "DOGECOIN", message: "hello" }),
undefined
);

expect(sendMessageSpy).toHaveBeenCalledWith(
"ERROR",
"unsupported message type",
requestId
);
});
});

describe("Embedded Key Override", () => {
const requestId = "test-request-id";
const serializedTransaction = JSON.stringify({
Expand Down
Loading
Loading