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.9876c027ef7327c209f1.js

This file was deleted.

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

This file was deleted.

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

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions export-and-sign/dist/bundle.dff20a3f8b4e5bcab5e4.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.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.dff20a3f8b4e5bcab5e4.js" integrity="sha384-EWnpB1H8M1QTL75sDBEnkg0dRYf5n2JwFRxJKniOjJxByluwIbVNT/amtYKjrQ9U" crossorigin="anonymous"></script></body></html>
188 changes: 183 additions & 5 deletions export-and-sign/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DEFAULT_TTL_MILLISECONDS,
onInjectKeyBundle,
onSignTransaction,
onClearEmbeddedPrivateKey,
getKeyNotFoundErrorMessage,
onResetToDefaultEmbeddedKey,
onSetEmbeddedKeyOverride,
Expand Down Expand Up @@ -868,7 +869,8 @@ describe("Embedded Key Override", () => {

// Mock raw 32-byte P-256 private key (embedded key)
// This is what Turnkey exports after HPKE decryption - raw key bytes, not a JWK.
const mockEmbeddedKeyBytes = new Uint8Array(32).fill(42);
// Return a fresh buffer each time since handlers zero key buffers in-place.
const makeMockEmbeddedKeyBytes = () => new Uint8Array(32).fill(42);

function buildBundle(organizationId = "org-test") {
const signedData = {
Expand Down Expand Up @@ -956,7 +958,9 @@ describe("Embedded Key Override", () => {

describe("SET_EMBEDDED_KEY_OVERRIDE handler", () => {
it("decrypts and stores the embedded key", async () => {
const HpkeDecryptMock = jest.fn().mockResolvedValue(mockEmbeddedKeyBytes);
const HpkeDecryptMock = jest
.fn()
.mockResolvedValue(makeMockEmbeddedKeyBytes());

await onSetEmbeddedKeyOverride(
requestId,
Expand All @@ -973,6 +977,20 @@ describe("Embedded Key Override", () => {
);
});

it("zeros decrypted key bytes on success", async () => {
const decrypted = new Uint8Array(32).fill(42);
const HpkeDecryptMock = jest.fn().mockResolvedValue(decrypted);

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

expect(decrypted.every((b) => b === 0)).toBe(true);
});

it("rejects invalid decryption key length", async () => {
const HpkeDecryptMock = jest
.fn()
Expand All @@ -988,14 +1006,30 @@ describe("Embedded Key Override", () => {
).rejects.toThrow("invalid decryption key length");
});

it("zeros decrypted key bytes on error", async () => {
const decrypted = new Uint8Array(16).fill(1);
const HpkeDecryptMock = jest.fn().mockResolvedValue(decrypted);

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

expect(decrypted.every((b) => b === 0)).toBe(true);
});

it("uses injected key for subsequent bundle decryptions", async () => {
// 1. Replace embedded key with embedded key
let callCount = 0;
const HpkeDecryptMock = jest.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
// First call: decrypting the embedded key bundle itself (uses embedded key)
return Promise.resolve(mockEmbeddedKeyBytes);
return Promise.resolve(makeMockEmbeddedKeyBytes());
}
// Subsequent calls: decrypting wallet bundles (should use the injected key)
return Promise.resolve(new Uint8Array(64).fill(9));
Expand Down Expand Up @@ -1037,7 +1071,9 @@ describe("Embedded Key Override", () => {
describe("RESET_TO_DEFAULT_EMBEDDED_KEY handler", () => {
it("clears the injected embedded key", async () => {
// 1. Replace embedded key
const HpkeDecryptMock = jest.fn().mockResolvedValue(mockEmbeddedKeyBytes);
const HpkeDecryptMock = jest
.fn()
.mockResolvedValue(makeMockEmbeddedKeyBytes());

await onSetEmbeddedKeyOverride(
requestId,
Expand Down Expand Up @@ -1072,14 +1108,156 @@ describe("Embedded Key Override", () => {
});
});

describe("Key clearing and buffer zeroing", () => {
it("clears all keys when no address is given and subsequent signing fails", async () => {
const HpkeDecryptMock = jest
.fn()
.mockResolvedValue(new Uint8Array(64).fill(9));

// Inject two keys
await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"SOLANA",
"wallet-x",
HpkeDecryptMock
);
await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"SOLANA",
"wallet-y",
HpkeDecryptMock
);

// Clear all keys (no address argument)
await onClearEmbeddedPrivateKey(requestId, undefined);

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

// After clearing, signing should throw "key bytes not found"
let signError;
try {
await onSignTransaction(requestId, serializedTransaction, "wallet-x");
} catch (e) {
signError = e.toString();
}
expect(signError).toContain("key bytes not found");

try {
await onSignTransaction(requestId, serializedTransaction, "wallet-y");
} catch (e) {
signError = e.toString();
}
expect(signError).toContain("key bytes not found");
});

it("clears only the targeted key and leaves other keys intact", async () => {
const HpkeDecryptMock = jest
.fn()
.mockResolvedValue(new Uint8Array(64).fill(9));

await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"SOLANA",
"wallet-keep",
HpkeDecryptMock
);
await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"SOLANA",
"wallet-remove",
HpkeDecryptMock
);

// Clear only wallet-remove
await onClearEmbeddedPrivateKey(requestId, "wallet-remove");

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

// wallet-remove should be gone -- signing throws
let signError;
try {
await onSignTransaction(
requestId,
serializedTransaction,
"wallet-remove"
);
} catch (e) {
signError = e.toString();
}
expect(signError).toContain("key bytes not found");

// wallet-keep should still be signable
await onSignTransaction(requestId, serializedTransaction, "wallet-keep");
expect(sendMessageSpy).toHaveBeenCalledWith(
"TRANSACTION_SIGNED",
expect.any(String),
requestId
);
});

it("zeros the Solana secretKey buffer on single-key clear", async () => {
const HpkeDecryptMock = jest
.fn()
.mockResolvedValue(new Uint8Array(64).fill(9));

await onInjectKeyBundle(
requestId,
"org-test",
buildBundle(),
"SOLANA",
"wallet-zero",
HpkeDecryptMock
);

// The mock Keypair.fromSecretKey always returns the same mockKeypair object.
// Capture the secretKey reference before clearing.
const { Keypair } = await import("@solana/web3.js");
const capturedSecretKey = Keypair.fromSecretKey().secretKey;

await onClearEmbeddedPrivateKey(requestId, "wallet-zero");

// zeroKeyEntry should have called fill(0) on the secretKey buffer.
// (It may already be zero if a prior test cleared the same mock keypair,
// but the important invariant is: it must be zero after a clear.)
expect(capturedSecretKey.every((b) => b === 0)).toBe(true);
});

it("sends error when trying to clear a key that does not exist", async () => {
await onClearEmbeddedPrivateKey(requestId, "nonexistent-wallet");

// onClearEmbeddedPrivateKey sends new Error(...).toString() which includes "Error: " prefix
expect(sendMessageSpy).toHaveBeenCalledWith(
"ERROR",
"Error: key not found for address nonexistent-wallet. Note that address is case sensitive.",
requestId
);
});
});

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