@turnkey/iframe-stamper
Version:
Iframe-based stamper for @turnkey/http
461 lines (458 loc) • 22.3 kB
JavaScript
'use strict';
/// <reference lib="dom" />
// Header name for an API key stamp
const stampHeaderName = "X-Stamp";
// Set of constants for event types expected to be sent and received between a parent page and its iframe.
exports.IframeEventType = void 0;
(function (IframeEventType) {
// Event sent by the iframe to its parent to indicate readiness.
// Value: the iframe public key
IframeEventType["PublicKeyReady"] = "PUBLIC_KEY_READY";
// Event sent by the parent to inject a credential bundle (for recovery or auth) into the iframe.
// Value: the bundle to inject
IframeEventType["InjectCredentialBundle"] = "INJECT_CREDENTIAL_BUNDLE";
// Event sent by the parent to inject a private key export bundle into the iframe.
// Value: the bundle to inject
// Key Format (optional): the key format to encode the private key in after it's exported and decrypted: HEXADECIMAL or SOLANA. Defaults to HEXADECIMAL.
// Public Key (optional): the public key of the exported private key. Required when the key format is SOLANA.
IframeEventType["InjectKeyExportBundle"] = "INJECT_KEY_EXPORT_BUNDLE";
// Event sent by the parent to inject a wallet export bundle into the iframe.
// Value: the bundle to inject
IframeEventType["InjectWalletExportBundle"] = "INJECT_WALLET_EXPORT_BUNDLE";
// Event sent by the parent to inject an import bundle into the iframe.
// Value: the bundle to inject
IframeEventType["InjectImportBundle"] = "INJECT_IMPORT_BUNDLE";
// Event sent by the parent to extract an encrypted wallet bundle from the iframe.
// Value: none
IframeEventType["ExtractWalletEncryptedBundle"] = "EXTRACT_WALLET_ENCRYPTED_BUNDLE";
// Event sent by the parent to extract an encrypted private key bundle from the iframe.
// Value: none
// Key Format (optional): the key format to decode the private key in before it's encrypted for import: HEXADECIMAL or SOLANA. Defaults to HEXADECIMAL.
IframeEventType["ExtractKeyEncryptedBundle"] = "EXTRACT_KEY_ENCRYPTED_BUNDLE";
// Event sent by the parent to apply settings on the iframe.
// Value: the settings to apply in JSON string format.
IframeEventType["ApplySettings"] = "APPLY_SETTINGS";
// Event sent by the iframe to its parent when `InjectBundle` is successful
// Value: true (boolean)
IframeEventType["BundleInjected"] = "BUNDLE_INJECTED";
// Event sent by the iframe to its parent when `ExtractEncryptedBundle` is successful
// Value: the bundle encrypted in the iframe
IframeEventType["EncryptedBundleExtracted"] = "ENCRYPTED_BUNDLE_EXTRACTED";
// Event sent by the iframe to its parent when `ApplySettings` is successful
// Value: true (boolean)
IframeEventType["SettingsApplied"] = "SETTINGS_APPLIED";
// Event sent by the iframe to its parent when `signTransaction` is successful
// Value: true (boolean)
IframeEventType["TransactionSigned"] = "TRANSACTION_SIGNED";
// Event sent by the iframe to its parent when `signMessage` is successful
// Value: true (boolean)
IframeEventType["MessageSigned"] = "MESSAGE_SIGNED";
// Event sent by the iframe to its parent when `clearEmbeddedPrivateKey` is successful
// Value: true (boolean)
IframeEventType["EmbeddedPrivateKeyCleared"] = "EMBEDDED_PRIVATE_KEY_CLEARED";
// Event sent by the parent page to request a signature
// Value: payload to sign
IframeEventType["StampRequest"] = "STAMP_REQUEST";
// Event sent by the iframe to communicate the result of a stamp operation.
// Value: signed payload
IframeEventType["Stamp"] = "STAMP";
// Event sent by the parent to establish secure communication via MessageChannel API.
// Value: MessageChannel port
IframeEventType["TurnkeyInitMessageChannel"] = "TURNKEY_INIT_MESSAGE_CHANNEL";
// Event sent by the parent to get the iframe target embedded key's public key.
// Value: the iframe public key
IframeEventType["GetEmbeddedPublicKey"] = "GET_EMBEDDED_PUBLIC_KEY";
// Event sent by the parent to clear the iframe's embedded key.
// Value: none
IframeEventType["ClearEmbeddedKey"] = "RESET_EMBEDDED_KEY";
// Event sent by the parent to initialize a new embedded key.
// Value: none
IframeEventType["InitEmbeddedKey"] = "INIT_EMBEDDED_KEY";
// Event sent by the parent page to request a signature for a transaction.
// Value: payload to sign
IframeEventType["SignTransaction"] = "SIGN_TRANSACTION";
// Event sent by the parent page to request a signature for a message.
// Value: payload to sign
IframeEventType["SignMessage"] = "SIGN_MESSAGE";
// Event sent by the parent page to request that the iframe embedded private key is cleared from memory.
// Value: none
IframeEventType["clearEmbeddedPrivateKey"] = "CLEAR_EMBEDDED_PRIVATE_KEY";
// Event sent by the parent to set an override for the iframe's embedded key.
IframeEventType["SetEmbeddedKeyOverride"] = "SET_EMBEDDED_KEY_OVERRIDE";
// Event sent by the iframe to clear the injected decryption key and use the default embedded key instead.
IframeEventType["ResetToDefaultEmbeddedKey"] = "RESET_TO_DEFAULT_EMBEDDED_KEY";
// Event sent by the iframe to communicate an error
// Value: serialized error
IframeEventType["Error"] = "ERROR";
})(exports.IframeEventType || (exports.IframeEventType = {}));
// Set of constants for private key formats. These formats map to the encoding type used on a private key before encrypting and importing it
// or after exporting it and decrypting it.
exports.KeyFormat = void 0;
(function (KeyFormat) {
// 64 hexadecimal digits. Key format used by MetaMask, MyEtherWallet, Phantom, Ledger, and Trezor for Ethereum and Tron keys
KeyFormat["Hexadecimal"] = "HEXADECIMAL";
// Key format used by Phantom and Solflare for Solana keys
KeyFormat["Solana"] = "SOLANA";
// Wallet Import Format used by Main Net Bitcoin wallets like Electrum and Bitcoin Core
KeyFormat["BitcoinMainNetWIF"] = "BITCOIN_MAINNET_WIF";
// Wallet Import Format used by Test Net Bitcoin wallets like Electrum and Bitcoin Core
KeyFormat["BitcoinTestNetWIF"] = "BITCOIN_TESTNET_WIF";
// Bech32 format used by modern Bitcoin wallets & Sui wallets
KeyFormat["SuiBech32"] = "SUI_BECH32";
})(exports.KeyFormat || (exports.KeyFormat = {}));
exports.MessageType = void 0;
(function (MessageType) {
MessageType["Ethereum"] = "ETHEREUM";
MessageType["Solana"] = "SOLANA";
})(exports.MessageType || (exports.MessageType = {}));
exports.TransactionType = void 0;
(function (TransactionType) {
TransactionType["Ethereum"] = "ETHEREUM";
TransactionType["Solana"] = "SOLANA";
})(exports.TransactionType || (exports.TransactionType = {}));
function generateUUID() {
return crypto.randomUUID();
}
/**
* Stamper to use with `@turnkey/http`'s `TurnkeyClient`
* Creating a stamper inserts an iframe in the current page.
*/
class IframeStamper {
/**
* Creates a new iframe stamper. This function _does not_ insert the iframe in the DOM.
* Call `.init()` to insert the iframe element in the DOM.
* @param {TIframeStamperConfig} config - Configuration object for the iframe stamper
* @throws {Error} When running in non-browser environment
* @throws {Error} When MessageChannel is not supported
* @throws {Error} When iframeContainer is not provided
* @throws {Error} When iframe element with the same ID already exists
*/
constructor(config) {
if (typeof window === "undefined") {
throw new Error("Cannot initialize iframe in non-browser environment");
}
if (typeof MessageChannel === "undefined") {
throw new Error("Cannot initialize iframe without MessageChannel support");
}
if (!config.iframeContainer) {
throw new Error("Iframe container cannot be found");
}
this.container = config.iframeContainer;
if (this.container.querySelector(`#${config.iframeElementId}`)) {
throw new Error(`Iframe element with ID ${config.iframeElementId} already exists`);
}
let iframe = window.document.createElement("iframe");
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox
// We do not need any other permission than running scripts for import/export/auth frames.
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
iframe.id = config.iframeElementId;
iframe.src = config.iframeUrl;
if (config.clearClipboardOnPaste ?? true) {
iframe.allow = "clipboard-write"; // Clipboard will clear when pasting in the iframe
}
this.iframe = iframe;
const iframeUrl = new URL(config.iframeUrl);
this.iframeOrigin = iframeUrl.origin;
// This is populated once the iframe is ready. Call `.init()` to kick off DOM insertion!
this.iframePublicKey = null;
/**
* The MessageChannel API is used to establish secure communication between two execution contexts.
* In this case, the parent page and the iframe.
* See https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel
*/
this.messageChannel = new MessageChannel();
// Initialize a pending requests tracker
this.pendingRequests = new Map();
}
/**
* Handles incoming messages from the iframe via MessageChannel
* @param {MessageEvent} event - Message event from the iframe
* @returns {void}
*/
onMessageHandler(event) {
const { type, value, requestId } = event.data || {};
// Handle messages without requestId (like PUBLIC_KEY_READY)
if (!requestId) {
if (type === exports.IframeEventType.PublicKeyReady) {
this.iframePublicKey = value;
return;
}
return;
}
const pendingRequest = this.pendingRequests.get(requestId);
if (!pendingRequest) {
console.warn(`Received response for unknown request: ${requestId}`);
return;
}
// Remove from pending requests
this.pendingRequests.delete(requestId);
if (type === exports.IframeEventType.Error) {
pendingRequest.reject(new Error(value));
return;
}
// Handle specific response types
switch (type) {
case exports.IframeEventType.Stamp:
pendingRequest.resolve({
stampHeaderName,
stampHeaderValue: value,
});
break;
default:
pendingRequest.resolve(value);
}
}
/**
* Inserts the iframe on the page and returns a promise resolving to the iframe's public key
* @param {number} [dangerouslyOverrideIframeKeyTtl] - Optional TTL override for the iframe's embedded key (default 48 hours). Only use this if you are intentional about the security implications.
* @returns {Promise<string>} The iframe's public key
* @throws {Error} When contentWindow or contentWindow.postMessage does not exist
*/
async init(dangerouslyOverrideIframeKeyTtl) {
return new Promise((resolve, reject) => {
this.container.appendChild(this.iframe);
this.iframe.addEventListener("load", () => {
if (!this.iframe.contentWindow?.postMessage) {
reject(new Error("contentWindow or contentWindow.postMessage does not exist"));
return;
}
this.iframe.contentWindow.postMessage({
type: exports.IframeEventType.TurnkeyInitMessageChannel,
dangerouslyOverrideIframeKeyTtl: dangerouslyOverrideIframeKeyTtl,
}, this.iframeOrigin, [this.messageChannel.port2]);
});
this.messageChannel.port1.onmessage = (event) => {
// Handle initial PublicKeyReady event
if (event.data?.type === exports.IframeEventType.PublicKeyReady) {
this.iframePublicKey = event.data.value;
resolve(event.data.value);
}
// Handle all other messages
this.onMessageHandler(event);
};
});
}
/**
* Removes the iframe from the DOM and cleans up all resources
* @returns {void}
*/
clear() {
this.messageChannel?.port1?.close();
this.messageChannel?.port2?.close();
this.iframe.remove();
this.pendingRequests.clear();
}
/**
* Returns the public key, or `null` if the underlying iframe isn't properly initialized.
* @returns {string | null} The iframe's public key or null
*/
publicKey() {
return this.iframePublicKey;
}
/**
* Returns the public key, or `null` if the underlying iframe isn't properly initialized.
* This differs from the above in that it reaches out to the live iframe to see if an embedded key exists.
* @returns {Promise<string | null>} The embedded public key or null
*/
async getEmbeddedPublicKey() {
const publicKey = await this.createRequest(exports.IframeEventType.GetEmbeddedPublicKey);
this.iframePublicKey = publicKey;
return publicKey;
}
/**
* Clears the embedded key within an iframe.
* @returns {Promise<null>} Returns null on success
*/
async clearEmbeddedKey() {
await this.createRequest(exports.IframeEventType.ClearEmbeddedKey);
this.iframePublicKey = "";
return null;
}
/**
* Creates a new embedded key within an iframe. If an embedded key already exists, this will return it.
* This is primarily to be used in conjunction with `clearEmbeddedKey()`: after an embedded key is cleared,
* this can be used to create a new one.
* @returns {Promise<string | null>} The newly created embedded public key
*/
async initEmbeddedKey() {
const publicKey = await this.createRequest(exports.IframeEventType.InitEmbeddedKey);
this.iframePublicKey = publicKey;
return publicKey;
}
/**
* Generic function to abstract away request creation
* @template T
* @param {IframeEventType} type - The type of iframe event to send
* @param {any} [payload={}] - Optional payload data to send with the request
* @returns {Promise<T>} Promise resolving to the expected response shape
*/
createRequest(type, payload = {}) {
return new Promise((resolve, reject) => {
const requestId = generateUUID();
this.pendingRequests.set(requestId, {
resolve,
reject,
requestId,
});
this.messageChannel.port1.postMessage({
type,
requestId,
...payload,
});
});
}
/**
* Function to inject a new credential into the iframe
* The bundle should be encrypted to the iframe's initial public key
* Encryption should be performed with HPKE (RFC 9180).
* This is used during recovery and auth flows.
* @param {string} bundle - The encrypted credential bundle to inject
* @returns {Promise<boolean>} Returns true on successful injection
*/
async injectCredentialBundle(bundle) {
return this.createRequest(exports.IframeEventType.InjectCredentialBundle, {
value: bundle,
});
}
/**
* Function to inject an export bundle into the iframe
* The bundle should be encrypted to the iframe's initial public key
* Encryption should be performed with HPKE (RFC 9180).
* The key format to encode the private key in after it's exported and decrypted: HEXADECIMAL or SOLANA. Defaults to HEXADECIMAL.
* This is used during the private key export flow.
* @param {string} bundle - The encrypted export bundle to inject
* @param {string} organizationId - The organization ID
* @param {KeyFormat} keyFormat - [Optional] The key format (HEXADECIMAL or SOLANA). Defaults to HEXADECIMAL
* @param {string} address - [Optional] Address corresponding to the key bundle (case sensitive)
* @returns {Promise<boolean>} Returns true on successful injection
*/
async injectKeyExportBundle(bundle, organizationId, keyFormat, address) {
return this.createRequest(exports.IframeEventType.InjectKeyExportBundle, {
value: bundle,
keyFormat,
organizationId,
address,
});
}
/**
* Function to inject an export bundle into the iframe
* The bundle should be encrypted to the iframe's initial public key
* Encryption should be performed with HPKE (RFC 9180).
* This is used during the wallet export flow.
* @param {string} bundle - The encrypted wallet export bundle to inject
* @param {string} organizationId - The organization ID
* @returns {Promise<boolean>} Returns true on successful injection
*/
async injectWalletExportBundle(bundle, organizationId) {
return this.createRequest(exports.IframeEventType.InjectWalletExportBundle, {
value: bundle,
organizationId,
});
}
/**
* Function to inject an import bundle into the iframe
* This is used to initiate either the wallet import flow or the private key import flow.
* @param {string} bundle - The import bundle to inject
* @param {string} organizationId - The organization ID
* @param {string} userId - The user ID
* @returns {Promise<boolean>} Returns true on successful injection
*/
async injectImportBundle(bundle, organizationId, userId) {
return this.createRequest(exports.IframeEventType.InjectImportBundle, {
value: bundle,
organizationId,
userId,
});
}
/**
* Function to extract an encrypted bundle from the iframe
* The bundle should be encrypted to Turnkey's Signer enclave's initial public key
* Encryption should be performed with HPKE (RFC 9180).
* This is used during the wallet import flow.
* @returns {Promise<string>} The encrypted wallet bundle
*/
async extractWalletEncryptedBundle() {
return this.createRequest(exports.IframeEventType.ExtractWalletEncryptedBundle);
}
/**
* Function to extract an encrypted bundle from the iframe
* The bundle should be encrypted to Turnkey's Signer enclave's initial public key
* Encryption should be performed with HPKE (RFC 9180).
* The key format to encode the private key in before it's encrypted and imported: HEXADECIMAL or SOLANA. Defaults to HEXADECIMAL.
* This is used during the private key import flow.
* @param {KeyFormat} [keyFormat] - The key format (HEXADECIMAL or SOLANA). Defaults to HEXADECIMAL
* @returns {Promise<string>} The encrypted key bundle
*/
async extractKeyEncryptedBundle(keyFormat) {
return this.createRequest(exports.IframeEventType.ExtractKeyEncryptedBundle, { keyFormat });
}
/**
* Function to apply settings on allowed parameters in the iframe
* This is used to style the HTML element used for plaintext in wallet and private key import.
* @param {TIframeSettings} settings - The settings object containing styles to apply
* @returns {Promise<boolean>} Returns true on successful application
*/
async applySettings(settings) {
return this.createRequest(exports.IframeEventType.ApplySettings, {
value: JSON.stringify(settings),
});
}
/**
* Function to sign a payload with the underlying iframe
* @param {string} payload - The payload to sign
* @returns {Promise<TStamp>} Object containing stamp header name and value
* @throws {Error} When iframe public key is null (init() not called/awaited)
*/
async stamp(payload) {
if (this.iframePublicKey === null) {
throw new Error("null iframe public key. Have you called/awaited .init()?");
}
return this.createRequest(exports.IframeEventType.StampRequest, {
value: payload,
});
}
/**
* Function to sign a message using an embedded private key in-memory within an iframe
* Returns the signed message string
* @param {TSignableMessage} message - The message to sign with type (Ethereum or Solana)
* @param {string} address - [Optional] Address to sign with
* @returns {Promise<string>} The signed message string
*/
async signMessage(message, address) {
return this.createRequest(exports.IframeEventType.SignMessage, {
value: JSON.stringify(message),
address,
});
}
/**
* Function to sign a transaction using an embedded private key in-memory within an iframe
* Returns the signed, serialized transaction payload
* @param {TSignableTransaction} transaction - The transaction to sign with type (Ethereum or Solana)
* @param {string} address - [Optional] Address to sign with
* @returns {Promise<string>} The signed, serialized transaction payload
*/
async signTransaction(transaction, address) {
return this.createRequest(exports.IframeEventType.SignTransaction, {
value: JSON.stringify(transaction),
address,
});
}
/**
* Function to clear the iframe's in-memory embedded private key. For now, we assume that there will be only one private key at most.
* @returns {Promise<boolean>} Returns true on successful clearing
*/
async clearEmbeddedPrivateKey() {
return this.createRequest(exports.IframeEventType.clearEmbeddedPrivateKey);
}
async setEmbeddedKeyOverride(organizationId, bundle) {
return this.createRequest(exports.IframeEventType.SetEmbeddedKeyOverride, {
organizationId,
value: bundle,
});
}
async resetToDefaultEmbeddedKey() {
return this.createRequest(exports.IframeEventType.ResetToDefaultEmbeddedKey);
}
}
exports.IframeStamper = IframeStamper;
//# sourceMappingURL=index.js.map