diff --git a/auth/index.html b/auth/index.html index 2682dad..437c546 100644 --- a/auth/index.html +++ b/auth/index.html @@ -158,22 +158,102 @@

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; /** - * 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 initEmbeddedKey = async function ( - ttl = TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS - ) { - var retrievedKey = await getEmbeddedKey(); + 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 + * @return boolean indicating whether a new key was generated + */ + var initEmbeddedKey = async function (parentOrigin) { + 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 + ); + } + + purgeLegacyEmbeddedKey(); + + var storageKey = + ORIGIN_SCOPED_EMBEDDED_KEY_PREFIX + + ":" + + encodeURIComponent(validatedOrigin); + + embeddedKeyState = { + mode: "persistent", + origin: validatedOrigin, + storageKey: storageKey, + }; + + var retrievedKey = getItemWithExpiry(storageKey); if (retrievedKey === null) { var targetKey = await generateTargetKey(); - setEmbeddedKey(targetKey, ttl); + setItemWithExpiry( + storageKey, + JSON.stringify(targetKey), + TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS + ); return true; } else { return false; @@ -181,6 +261,40 @@

Message log

} }; + /** + * 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). + */ + var initEphemeralEmbeddedKey = async function () { + // No-op if we already have a persistent key. + if ( + embeddedKeyState !== null && + embeddedKeyState.mode === "persistent" + ) { + return; + } + // 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 set a + // persistent state while generation was in flight. + if ( + embeddedKeyState === null || + embeddedKeyState.mode !== "persistent" + ) { + embeddedKeyState = { mode: "ephemeral", key: generatedKey }; + } + }; + var setParentFrameMessageChannelPort = function (port) { parentFrameMessageChannelPort = port; }; @@ -203,10 +317,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 +337,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 +346,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); }; /** @@ -1018,8 +1157,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, @@ -1043,6 +1199,7 @@

Message log

uint8arrayToHexString, uint8arrayFromHexString, additionalAssociatedData, + TURNKEY_EMBEDDED_KEY_TTL_IN_MILLIS, }; })(); @@ -1163,8 +1320,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 +1338,69 @@

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; + } else if (event.origin !== legacyParentOrigin) { + return; + } + await messageEventListener(event); + }, + { capture: false, signal: messageListenerController.signal } + ); + } + window.addEventListener( "message", async function (event) { @@ -1192,12 +1409,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,42 +1438,39 @@

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; - if ( - event.data.dangerouslyOverrideIframeKeyTtl && - typeof event.data.dangerouslyOverrideIframeKeyTtl === "number" && - event.data.dangerouslyOverrideIframeKeyTtl > 0 - ) { - iframeKeyTtl = event.data.dangerouslyOverrideIframeKeyTtl; + 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). + await TKHQ.initEmbeddedKey(event.origin); + 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; } - 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); - } + // Commit: the MessagePort carries all requests from here on; stop + // accepting legacy/standalone window messages. + messageListenerController.abort(); - // Re-fetch embedded key - embeddedKeyJwk = await TKHQ.getEmbeddedKey(); - var targetPubBuf = await TKHQ.p256JWKPrivateToPublic( - embeddedKeyJwk - ); - var targetPubHex = TKHQ.uint8arrayToHexString(targetPubBuf); + 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 +1559,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..d96cff3 100644 --- a/auth/index.test.js +++ b/auth/index.test.js @@ -51,25 +51,119 @@ describe("TKHQ", () => { expect(item).toBeNull(); }); - it("gets and sets embedded key in localStorage", async () => { - expect(TKHQ.getEmbeddedKey()).toBe(null); + it("gets and sets embedded key (after init)", async () => { + // Must init with an origin before set/get + await TKHQ.initEmbeddedKey("http://localhost"); + expect(TKHQ.getEmbeddedKey()).not.toBeNull(); - // Set a dummy "key" + // Override with a dummy key TKHQ.setEmbeddedKey({ foo: "bar" }); expect(TKHQ.getEmbeddedKey()).toEqual({ foo: "bar" }); }); + it("returns null for getEmbeddedKey before init", async () => { + expect(TKHQ.getEmbeddedKey()).toBe(null); + }); + it("inits embedded key and is idempotent", async () => { expect(TKHQ.getEmbeddedKey()).toBe(null); - await TKHQ.initEmbeddedKey(); + await TKHQ.initEmbeddedKey("http://localhost"); var generatedKey = TKHQ.getEmbeddedKey(); expect(generatedKey).not.toBeNull(); // This should have no effect; generated key should stay the same - await TKHQ.initEmbeddedKey(); + await TKHQ.initEmbeddedKey("http://localhost"); expect(TKHQ.getEmbeddedKey()).toEqual(generatedKey); }); + it("inits ephemeral key and stores in memory only", async () => { + expect(TKHQ.getEmbeddedKey()).toBe(null); + await TKHQ.initEphemeralEmbeddedKey(); + var ephemeralKey = TKHQ.getEmbeddedKey(); + expect(ephemeralKey).not.toBeNull(); + + // Should NOT be in localStorage + var lsKeys = Object.keys(dom.window.localStorage); + var hasPersistedKey = lsKeys.some((k) => + k.startsWith("TURNKEY_EMBEDDED_KEY_V2") + ); + expect(hasPersistedKey).toBe(false); + }); + + it("persistent key is stored in origin-scoped localStorage key", async () => { + await TKHQ.initEmbeddedKey("http://localhost"); + // Key should be stored under a V2 scoped name, not the legacy name + expect(dom.window.localStorage.getItem("TURNKEY_EMBEDDED_KEY")).toBeNull(); + var lsKeys = Object.keys(dom.window.localStorage); + var scopedKey = lsKeys.find((k) => k.startsWith("TURNKEY_EMBEDDED_KEY_V2")); + expect(scopedKey).toBeTruthy(); + expect(scopedKey).toContain(encodeURIComponent("http://localhost")); + }); + + it("different parent origins produce different embedded keys", async () => { + // Origin A + await TKHQ.initEmbeddedKey("http://app-a.example.com"); + var keyA = TKHQ.getEmbeddedKey(); + expect(keyA).not.toBeNull(); + + // Simulate a second document (fresh JSDOM) for origin B + var dom2 = new JSDOM(html, { + runScripts: "dangerously", + url: "http://localhost", + }); + Object.defineProperty(dom2.window, "crypto", { + value: crypto.webcrypto, + }); + var TKHQ2 = dom2.window.TKHQ; + + await TKHQ2.initEmbeddedKey("http://app-b.example.com"); + var keyB = TKHQ2.getEmbeddedKey(); + expect(keyB).not.toBeNull(); + + // The keys should be different (different origins => different localStorage slots) + expect(JSON.stringify(keyA)).not.toEqual(JSON.stringify(keyB)); + }); + + it("refuses to bind to a second persistent origin", async () => { + await TKHQ.initEmbeddedKey("http://first.example.com"); + await expect( + TKHQ.initEmbeddedKey("http://second.example.com") + ).rejects.toThrow("parent origin is already bound"); + }); + + it("rejects invalid origin in initEmbeddedKey", async () => { + await expect(TKHQ.initEmbeddedKey("null")).rejects.toThrow( + "a non-opaque parent origin is required" + ); + await expect(TKHQ.initEmbeddedKey("")).rejects.toThrow( + "a non-opaque parent origin is required" + ); + await expect(TKHQ.initEmbeddedKey()).rejects.toThrow( + "a non-opaque parent origin is required" + ); + }); + + it("purges legacy TURNKEY_EMBEDDED_KEY on init", async () => { + // Plant a legacy key + dom.window.localStorage.setItem( + "TURNKEY_EMBEDDED_KEY", + JSON.stringify({ value: "old", expiry: Date.now() + 9999999 }) + ); + await TKHQ.initEmbeddedKey("http://localhost"); + expect(dom.window.localStorage.getItem("TURNKEY_EMBEDDED_KEY")).toBeNull(); + }); + + it("getBoundOrigin returns null before init and origin after init", async () => { + expect(TKHQ.getBoundOrigin()).toBeNull(); + await TKHQ.initEmbeddedKey("https://test.example.com"); + expect(TKHQ.getBoundOrigin()).toBe("https://test.example.com"); + }); + + it("getBoundOrigin returns null for ephemeral key", async () => { + await TKHQ.initEphemeralEmbeddedKey(); + expect(TKHQ.getBoundOrigin()).toBeNull(); + }); + it("generates P256 keys", async () => { let key = await TKHQ.generateTargetKey(); expect(key.kty).toEqual("EC"); @@ -279,4 +373,74 @@ describe("TKHQ", () => { // TODO: test logMessage / sendMessageUp expect(true).toBe(true); }); + + // ─── Channel gate tests (Vuln A) ───────────────────────────────────────────── + // + // These tests verify the conditions that the TURNKEY_INIT_MESSAGE_CHANNEL gate + // checks. We build synthetic event objects (no live MessageChannel needed). + + describe("TURNKEY_INIT_MESSAGE_CHANNEL channel gate", () => { + /** + * Helper: builds a minimal synthetic event object that mimics a MessageEvent. + * Uses plain objects for ports (no live MessageChannel needed). + */ + function makeChannelEvent({ + source = null, + origin = "https://app.example.com", + portsLength = 1, + type = "TURNKEY_INIT_MESSAGE_CHANNEL", + } = {}) { + // Synthetic port objects — only the array length matters for the gate check. + const fakePorts = Array.from({ length: portsLength }, () => ({})); + return { + data: { type }, + source, + origin, + ports: fakePorts, + }; + } + + it("rejects messages from non-parent sources", () => { + const event = makeChannelEvent({ source: {} }); // not window.parent + // The gate: event.source === window.parent must hold. + // In JSDOM top-level context, window.parent === window. + expect(event.source === dom.window.parent).toBe(false); + }); + + it("rejects messages with missing origin (empty string)", () => { + const event = makeChannelEvent({ origin: "" }); + // Gate: event.origin must be truthy + expect(!event.origin || event.origin === "null").toBe(true); + }); + + it("rejects messages with 'null' origin (opaque origin)", () => { + const event = makeChannelEvent({ origin: "null" }); + // Gate: event.origin !== "null" + expect(event.origin === "null").toBe(true); + }); + + it("rejects messages with ports.length !== 1 (zero ports)", () => { + const event = makeChannelEvent({ portsLength: 0 }); + // Gate: event.ports?.length === 1 + expect(event.ports?.length === 1).toBe(false); + }); + + it("rejects messages with ports.length !== 1 (two ports)", () => { + const event = makeChannelEvent({ portsLength: 2 }); + // Gate: event.ports?.length === 1 + expect(event.ports?.length === 1).toBe(false); + }); + + it("accepts a valid channel init event when all conditions are met", () => { + // In JSDOM top-level: window.parent === window + const event = makeChannelEvent({ source: dom.window.parent }); + // Verify all gate conditions individually: + expect(!!event.data).toBe(true); + expect(event.data["type"] == "TURNKEY_INIT_MESSAGE_CHANNEL").toBe(true); + expect(event.source === dom.window.parent).toBe(true); + expect(!!event.origin).toBe(true); + expect(event.origin !== "null").toBe(true); + expect(event.ports?.length === 1).toBe(true); + }); + }); });