diff --git a/auth/index.html b/auth/index.html index 2682dad..e490e49 100644 --- a/auth/index.html +++ b/auth/index.html @@ -158,26 +158,186 @@

Message log

window.isInitEmbeddedKeyRunByDomContentLoaded = false; window.TKHQ = (function () { /** constant for LocalStorage */ - var TURNKEY_EMBEDDED_KEY = "TURNKEY_EMBEDDED_KEY"; + // Legacy fixed key name — never migrated; purged on first init. + var LEGACY_EMBEDDED_KEY = "TURNKEY_EMBEDDED_KEY"; + // V2 origin-scoped key prefix. + var ORIGIN_SCOPED_EMBEDDED_KEY_PREFIX = "TURNKEY_EMBEDDED_KEY_V2"; var TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS = 1000 * 60 * 60 * 48; // 48 hours in milliseconds var parentFrameMessageChannelPort = null; + // Target origin for window.parent.postMessage in legacy mode. + // Starts as "*" (only for PUBLIC_KEY_READY before any origin is known). + // Set to the bound parent origin on the first valid legacy message so + // subsequent outbound responses are delivered only to that origin. + var parentFrameTargetOrigin = "*"; + /** - * Creates a new public/private key pair and persists it in localStorage - * @return boolean indicating whether an embedded key has been set + * Embedded-key state for this document. + * Two modes: + * { mode: "persistent", storageKey } -- key lives in localStorage scoped to the + * browser-authenticated parent origin (or this document's own origin in standalone). + * { mode: "ephemeral", key } -- key lives in memory only. Used for legacy + * (@turnkey/iframe-stamper < 2.1.0) parents so a malicious embedder can never + * obtain a key that decrypts another application's bundles (INT-697). + */ + var embeddedKeyState = null; + + /** + * Validates that a parent origin string is non-empty, non-opaque, and + * a valid serialized origin (scheme + host + optional port only). + * @param {string} parentOrigin + * @returns {string} the validated origin + */ + var validateParentOrigin = function (parentOrigin) { + if ( + typeof parentOrigin !== "string" || + parentOrigin.length === 0 || + parentOrigin === "null" + ) { + throw new Error("a non-opaque parent origin is required"); + } + var parsedOrigin = new URL(parentOrigin).origin; + if (parsedOrigin !== parentOrigin) { + throw new Error("invalid parent origin: " + parentOrigin); + } + return parsedOrigin; + }; + + /** + * Purge the legacy global key so old bundles encrypted to it can no longer be + * replayed. Never migrate it: migration would preserve the cross-origin replay + * vulnerability for bundles encrypted before this change. + */ + var purgeLegacyEmbeddedKey = function () { + window.localStorage.removeItem(LEGACY_EMBEDDED_KEY); + }; + + /** + * Creates (if needed) the persistent embedded key scoped to the given parent + * origin and persists it in localStorage. Must be called with a valid, + * non-opaque origin string (e.g. "https://app.example.com"). + * A document can bind to exactly one parent origin; an ephemeral key, if any, + * is superseded. + * @param {string} parentOrigin + * @param {number} [ttl] expiry in milliseconds; defaults to TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS (48 h) + * @return boolean indicating whether a new key was generated */ var initEmbeddedKey = async function ( + parentOrigin, ttl = TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS ) { - var retrievedKey = await getEmbeddedKey(); - if (retrievedKey === null) { - var targetKey = await generateTargetKey(); - setEmbeddedKey(targetKey, ttl); - return true; - } else { - return false; - // Nothing to do, key is correctly initialized! + var validatedOrigin = validateParentOrigin(parentOrigin); + + // If already bound to a persistent key for a DIFFERENT origin, refuse. + if ( + embeddedKeyState !== null && + embeddedKeyState.mode === "persistent" && + embeddedKeyState.origin !== validatedOrigin + ) { + throw new Error( + "parent origin is already bound to " + + embeddedKeyState.origin + + "; refusing " + + validatedOrigin + ); + } + + // If already initializing or initialized for the same origin, await + // the existing ready promise so concurrent callers serialise on it. + if ( + embeddedKeyState !== null && + embeddedKeyState.mode === "persistent" && + embeddedKeyState.origin === validatedOrigin + ) { + return await embeddedKeyState.ready; + } + + purgeLegacyEmbeddedKey(); + + var storageKey = + ORIGIN_SCOPED_EMBEDDED_KEY_PREFIX + + ":" + + encodeURIComponent(validatedOrigin); + + // Retain the previous state so we can roll back on storage failure. + var previousState = embeddedKeyState; + + var state = { + mode: "persistent", + origin: validatedOrigin, + storageKey: storageKey, + }; + + // Activate the new state synchronously so concurrent ephemeral inits + // cannot clobber it. `ready` resolves once the key is persisted; + // concurrent readers must await it before calling getEmbeddedKey. + // On rejection, roll back to previousState so a retry is possible. + state.ready = (async function () { + var retrievedKey = getItemWithExpiry(storageKey); + if (retrievedKey === null) { + var targetKey = await generateTargetKey(); + setItemWithExpiry(storageKey, JSON.stringify(targetKey), ttl); + } + })().catch(function (error) { + if (embeddedKeyState === state) { + embeddedKeyState = previousState; + } + throw error; + }); + + embeddedKeyState = state; + return await state.ready; + }; + + /** + * Creates (if needed) an in-memory embedded key unique to this document, for + * legacy (@turnkey/iframe-stamper < 2.1.0) parents. No-ops if a persistent + * origin-scoped key is already active (i.e. the MessageChannel handshake + * completed first). If a persistent key is in-flight (state.ready not yet + * resolved), awaits it so callers can read the key once persisted; if the + * persistent init fails and rolls itself back, the ephemeral key takes over. + */ + var initEphemeralEmbeddedKey = async function () { + // If a persistent state is already active (including in-flight), wait + // for the ready promise so callers can read the key once persisted. + if ( + embeddedKeyState !== null && + embeddedKeyState.mode === "persistent" + ) { + try { + return await embeddedKeyState.ready; + } catch (_e) { + // Persistent init failed and rolled back; fall through to ephemeral. + } + } + // No-op if we already have an ephemeral key. + if ( + embeddedKeyState !== null && + embeddedKeyState.mode === "ephemeral" && + embeddedKeyState.key !== null + ) { + return; + } + purgeLegacyEmbeddedKey(); + var generatedKey = await generateTargetKey(); + // Re-check: a concurrent MessageChannel handshake may have activated a + // persistent state while generation was in flight; it must not be + // clobbered. Wait for it so callers can read the key; if it fails and + // rolls itself back, the ephemeral key takes over below. + var latestState = embeddedKeyState; + if (latestState !== null && latestState.mode === "persistent") { + try { + return await latestState.ready; + } catch (_e) { + // fall through to the ephemeral key + } + } + if ( + embeddedKeyState === null || + embeddedKeyState.mode !== "persistent" + ) { + embeddedKeyState = { mode: "ephemeral", key: generatedKey }; } }; @@ -185,6 +345,34 @@

Message log

parentFrameMessageChannelPort = port; }; + /** + * Restricts outbound window.parent.postMessage calls to the given origin. + * Called once the first valid legacy message binds the parent origin so that + * subsequent responses cannot be intercepted by a replacement page. + * @param {string} origin + */ + var setParentFrameOrigin = function (origin) { + if (typeof origin !== "string" || origin.length === 0) { + throw new Error( + "a canonical, non-opaque parent frame origin is required" + ); + } + var parsedOrigin; + try { + parsedOrigin = new URL(origin).origin; + } catch (_e) { + throw new Error( + "a canonical, non-opaque parent frame origin is required" + ); + } + if (parsedOrigin === "null" || parsedOrigin !== origin) { + throw new Error( + "a canonical, non-opaque parent frame origin is required" + ); + } + parentFrameTargetOrigin = parsedOrigin; + }; + /* * Generate a key to encrypt to and export it as a JSON Web Key. */ @@ -203,10 +391,18 @@

Message log

/** * Gets the current embedded private key JWK. + * Reads from ephemeral memory or origin-scoped localStorage depending on state. * @return `null` if not found. */ var getEmbeddedKey = function () { - var jwtKey = getItemWithExpiry(TURNKEY_EMBEDDED_KEY); + if (embeddedKeyState === null) { + return null; + } + if (embeddedKeyState.mode === "ephemeral") { + return embeddedKeyState.key || null; + } + // persistent mode + var jwtKey = getItemWithExpiry(embeddedKeyState.storageKey); if (!jwtKey) { return null; } else { @@ -215,7 +411,8 @@

Message log

}; /** - * Sets the current embedded private key JWK in local storage. + * Sets the current embedded private key JWK. + * Writes to ephemeral memory or origin-scoped localStorage depending on state. * @param {string} targetKey the private key JWK * @param {number} ttl (optional) if unset, default to TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS */ @@ -223,18 +420,34 @@

Message log

targetKey, ttl = TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS ) { + if (embeddedKeyState === null) { + throw new Error("embedded key has not been initialized"); + } + if (embeddedKeyState.mode === "ephemeral") { + embeddedKeyState.key = targetKey; + return; + } + // persistent mode return setItemWithExpiry( - TURNKEY_EMBEDDED_KEY, + embeddedKeyState.storageKey, JSON.stringify(targetKey), ttl ); }; /** - * Removes the embedded key from local storage. + * Removes the embedded key from its current storage location. */ var resetEmbeddedKey = function () { - window.localStorage.removeItem(TURNKEY_EMBEDDED_KEY); + if (embeddedKeyState === null) { + return; + } + if (embeddedKeyState.mode === "ephemeral") { + embeddedKeyState.key = null; + return; + } + // persistent mode + window.localStorage.removeItem(embeddedKeyState.storageKey); }; /** @@ -555,13 +768,7 @@

Message log

if (parentFrameMessageChannelPort) { parentFrameMessageChannelPort.postMessage(message); } else if (window.parent !== window) { - window.parent.postMessage( - { - type: type, - value: value, - }, - "*" - ); + window.parent.postMessage(message, parentFrameTargetOrigin); } logMessage(`⬆️ Sent message ${type}: ${value}`); }; @@ -1018,8 +1225,25 @@

Message log

* End of private crypto implementation for P256 public key derivation from a private key. *********************************************************************************************/ + /** + * Returns the origin to which this document's embedded key is currently bound, + * or null if the key is ephemeral or not yet initialized. + * @return {string|null} + */ + var getBoundOrigin = function () { + if ( + embeddedKeyState !== null && + embeddedKeyState.mode === "persistent" + ) { + return embeddedKeyState.origin; + } + return null; + }; + return { initEmbeddedKey, + initEphemeralEmbeddedKey, + getBoundOrigin, getEmbeddedKey, generateTargetKey, setItemWithExpiry, @@ -1034,6 +1258,7 @@

Message log

convertEcdsaIeee1363ToDer, sendMessageUp, setParentFrameMessageChannelPort, + setParentFrameOrigin, logMessage, base64urlEncode, base64urlDecode, @@ -1043,6 +1268,7 @@

Message log

uint8arrayToHexString, uint8arrayFromHexString, additionalAssociatedData, + TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS, }; })(); @@ -1163,8 +1389,17 @@

Message log

document.addEventListener( "DOMContentLoaded", async function () { - // Initialize embedded key. Currently don't need to use the return value. - await TKHQ.initEmbeddedKey(); + if (window.parent === window) { + // Standalone mode: persist key scoped to this document's own origin. + await TKHQ.initEmbeddedKey(window.location.origin); + } else { + // Embedded mode: create an ephemeral document-scoped key for legacy + // (@turnkey/iframe-stamper < 2.1.0) clients, which passively wait for + // PUBLIC_KEY_READY. Modern clients supersede it during the MessageChannel + // handshake below with a persistent key scoped to their browser-authenticated + // origin (INT-697). + await TKHQ.initEphemeralEmbeddedKey(); + } window.isInitEmbeddedKeyRunByDomContentLoaded = true; var embeddedKeyJwk = await TKHQ.getEmbeddedKey(); @@ -1172,18 +1407,73 @@

Message log

var targetPubHex = TKHQ.uint8arrayToHexString(targetPubBuf); document.getElementById("embedded-key").value = targetPubHex; - window.addEventListener("message", messageEventListener, { - capture: false, - signal: messageListenerController.signal, - }); - - addDOMEventListeners(); - - TKHQ.sendMessageUp("PUBLIC_KEY_READY", targetPubHex); + if (window.parent === window) { + // Standalone mode: DOM forms drive the flow by posting messages to + // this same window. Add DOM form listeners and accept only self-posts. + addDOMEventListeners(); + window.addEventListener( + "message", + async function (event) { + if (event.source !== window) { + return; + } + await messageEventListener(event); + }, + { + capture: false, + signal: messageListenerController.signal, + } + ); + } else { + // Announce the ephemeral public key for legacy clients that don't use + // MessageChannel (they passively wait for this event). + TKHQ.sendMessageUp("PUBLIC_KEY_READY", targetPubHex); + } }, false ); + // Origin of a legacy (@turnkey/iframe-stamper < 2.1.0) parent, bound on its + // first valid message. A document belongs to exactly one embedder for its + // lifetime, so every subsequent message must come from the same origin. + let legacyParentOrigin = null; + + // The legacy messageEventListener wrapper enforces source+origin checks. + // Only active in embedded mode (window.parent !== window). + if (window.parent !== window) { + window.addEventListener( + "message", + async function (event) { + if (!event.data || !event.data["type"]) { + return; + } + // Channel establishment is owned by the handshake listener below. + if (event.data["type"] === "TURNKEY_INIT_MESSAGE_CHANNEL") { + return; + } + // Only the direct parent, with a real (non-opaque) origin, may drive + // the legacy path. + if (event.source !== window.parent) { + return; + } + if (!event.origin || event.origin === "null") { + return; + } + if (legacyParentOrigin === null) { + legacyParentOrigin = event.origin; + // Restrict outbound responses to this origin so they cannot be + // intercepted by a replacement page navigated in while an async + // request is in flight (Comment 3 fix). + TKHQ.setParentFrameOrigin(event.origin); + } else if (event.origin !== legacyParentOrigin) { + return; + } + await messageEventListener(event); + }, + { capture: false, signal: messageListenerController.signal } + ); + } + window.addEventListener( "message", async function (event) { @@ -1192,12 +1482,26 @@

Message log

* The parent frame sends a TURNKEY_INIT_MESSAGE_CHANNEL event with the MessagePort. * If we receive this event, we want to remove the message event listener that was added in the DOMContentLoaded event to avoid processing messages twice. * We persist the MessagePort so we can use it to communicate with the parent window in subsequent calls to TKHQ.sendMessageUp + * + * Vuln A fix: Validate event.source, event.origin, and ports.length. */ if ( event.data && event.data["type"] == "TURNKEY_INIT_MESSAGE_CHANNEL" && - event.ports?.[0] + event.source === window.parent && + event.origin && + event.origin !== "null" && + event.ports?.length === 1 ) { + // A legacy-bound document may upgrade to the MessageChannel protocol, + // but never on behalf of a different origin. + if ( + legacyParentOrigin !== null && + legacyParentOrigin !== event.origin + ) { + return; + } + // 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 @@ -1207,15 +1511,9 @@

Message log

} channelEstablished = true; - // remove the message event listener that was added in the DOMContentLoaded event - messageListenerController.abort(); - iframeMessagePort = event.ports[0]; - iframeMessagePort.onmessage = messageEventListener; - TKHQ.setParentFrameMessageChannelPort(iframeMessagePort); - - let iframeKeyTtl = TKHQ.TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS; + var iframeKeyTtl = TKHQ.TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS; if ( event.data.dangerouslyOverrideIframeKeyTtl && typeof event.data.dangerouslyOverrideIframeKeyTtl === "number" && @@ -1224,25 +1522,38 @@

Message log

iframeKeyTtl = event.data.dangerouslyOverrideIframeKeyTtl; } - var embeddedKeyJwk = await TKHQ.getEmbeddedKey(); - - // If the embedded key is not initialized or not available, reset it - if ( - !(window.isInitEmbeddedKeyRunByDomContentLoaded && embeddedKeyJwk) - ) { - await TKHQ.resetEmbeddedKey(); - document.getElementById("embedded-key").value = ""; - await TKHQ.initEmbeddedKey(iframeKeyTtl); + var targetPubHex; + try { + // Vuln B fix: Supersede the ephemeral key (if any) with a persistent + // key scoped to the browser-authenticated parent origin (INT-697). + // Honour the caller's TTL override, if provided and valid. + await TKHQ.initEmbeddedKey(event.origin, iframeKeyTtl); + var embeddedKeyJwk = await TKHQ.getEmbeddedKey(); + var targetPubBuf = await TKHQ.p256JWKPrivateToPublic( + embeddedKeyJwk + ); + targetPubHex = TKHQ.uint8arrayToHexString(targetPubBuf); + } catch (e) { + // Key setup failed (e.g. blocked third-party storage). Roll back so + // the parent can retry the handshake, and keep the legacy/standalone + // listener alive so the frame stays functional in the meantime. + channelEstablished = false; + iframeMessagePort.postMessage({ + type: "ERROR", + value: e.toString(), + }); + return; } - // Re-fetch embedded key - embeddedKeyJwk = await TKHQ.getEmbeddedKey(); - var targetPubBuf = await TKHQ.p256JWKPrivateToPublic( - embeddedKeyJwk - ); - var targetPubHex = TKHQ.uint8arrayToHexString(targetPubBuf); + // Commit: the MessagePort carries all requests from here on; stop + // accepting legacy/standalone window messages. + messageListenerController.abort(); + + TKHQ.setParentFrameMessageChannelPort(iframeMessagePort); document.getElementById("embedded-key").value = targetPubHex; + iframeMessagePort.onmessage = messageEventListener; + TKHQ.sendMessageUp("PUBLIC_KEY_READY", targetPubHex); // remove the listener for TURNKEY_INIT_MESSAGE_CHANNEL after it's been processed @@ -1331,10 +1642,19 @@

Message log

/** * Function triggered when INIT_EMBEDDED_KEY event is received. + * Re-initializes the embedded key using the current mode/origin binding. * @param {string} requestId: idempotency key */ var onInitEmbeddedKey = async function (requestId) { - await TKHQ.initEmbeddedKey(); + // Re-use the bound origin if we're in persistent mode, otherwise ephemeral. + var boundOrigin = TKHQ.getBoundOrigin(); + if (boundOrigin !== null) { + await TKHQ.initEmbeddedKey(boundOrigin); + } else if (window.parent === window) { + await TKHQ.initEmbeddedKey(window.location.origin); + } else { + await TKHQ.initEphemeralEmbeddedKey(); + } var embeddedKeyJwk = await TKHQ.getEmbeddedKey(); var targetPubBuf = await TKHQ.p256JWKPrivateToPublic(embeddedKeyJwk); var targetPubHex = TKHQ.uint8arrayToHexString(targetPubBuf); diff --git a/auth/index.test.js b/auth/index.test.js index caa8141..2126844 100644 --- a/auth/index.test.js +++ b/auth/index.test.js @@ -6,6 +6,20 @@ import * as crypto from "crypto"; const html = fs.readFileSync(path.resolve(__dirname, "./index.html"), "utf8"); +// Extract the body of the