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