Skip to content

fix(auth): scope embedded key to parent origin + validate MessageChannel sender (ENG-4596) - #131

Open
justinformentin wants to merge 1 commit into
mainfrom
jf/eng-4596-auth-frame-cross-origin-hardening
Open

fix(auth): scope embedded key to parent origin + validate MessageChannel sender (ENG-4596)#131
justinformentin wants to merge 1 commit into
mainfrom
jf/eng-4596-auth-frame-cross-origin-hardening

Conversation

@justinformentin

Copy link
Copy Markdown

Summary

This PR ports the INT-697 cross-origin hardening pattern to the auth frame, closing two related vulnerabilities (ENG-4596 / TKA-20260806-016, Medium).

The auth frame is structurally different from export-and-sign: the source is entirely inline in auth/index.html (no src/ directory, no build step). This PR adapts the same security patterns faithfully to the inline-script architecture.


Vuln A — MessageChannel gate accepts any sender

Before: The TURNKEY_INIT_MESSAGE_CHANNEL handler gated only on event.ports?.[0], allowing any window (not just the direct parent) to establish the privileged MessageChannel and receive subsequent messages via the port.

// Before
if (
  event.data &&
  event.data["type"] == "TURNKEY_INIT_MESSAGE_CHANNEL" &&
  event.ports?.[0]
)

After: The gate now validates source, origin, and port count — mirroring the INT-697 export-and-sign fix:

// After
if (
  event.data &&
  event.data["type"] == "TURNKEY_INIT_MESSAGE_CHANNEL" &&
  event.source === window.parent &&
  event.origin &&
  event.origin !== "null" &&
  event.ports?.length === 1
)

Vuln B — Parent-agnostic embedded key

Before: A single P-256 private key was persisted in localStorage under the fixed name "TURNKEY_EMBEDDED_KEY", created at DOMContentLoaded before any parent origin was known. The same key (and thus the same PUBLIC_KEY_READY public key) was served to every embedder. An attacker could replay an auth bundle encrypted to that key from any origin.

After: The inline TKHQ IIFE now tracks an embeddedKeyState object with two modes:

  • persistent — key stored in localStorage under TURNKEY_EMBEDDED_KEY_V2:<encodeURIComponent(origin)>. Each parent origin gets its own isolated key slot.
  • ephemeral — key stored in memory only (for legacy @turnkey/iframe-stamper < 2.1.0 clients). Unique to this document; never persisted.

Key new helpers added inline:

  • validateParentOrigin(origin) — validates non-empty, non-opaque, valid serialized origin
  • purgeLegacyEmbeddedKey() — removes old TURNKEY_EMBEDDED_KEY (never migrated)
  • initEmbeddedKey(origin) — now REQUIRES an origin; creates/reuses V2 scoped persistent key
  • initEphemeralEmbeddedKey() — creates in-memory-only key; no-ops if persistent key is active
  • getBoundOrigin() — returns the currently bound origin
  • Updated getEmbeddedKey(), setEmbeddedKey(), resetEmbeddedKey() to dispatch based on state mode

Flow changes:

  • Standalone mode (DOMContentLoaded): initEmbeddedKey(window.location.origin)
  • Embedded mode (DOMContentLoaded): initEphemeralEmbeddedKey() for legacy clients
  • MessageChannel handshake: initEmbeddedKey(event.origin) supersedes ephemeral; rolls back on failure
  • Legacy embedded path: binds to first sender origin; rejects different origins

Tests

Added/updated in auth/index.test.js (34 total, all passing):

New Vuln B tests: different origins produce different keys, refuses second origin binding, rejects invalid origins, purges legacy key, ephemeral key stays in memory only, getBoundOrigin coverage.

New Vuln A tests (channel gate): rejects non-parent source, empty origin, opaque "null" origin, 0 ports, 2 ports; accepts valid event.


Validation Results

prettier:check  — All matched files use Prettier code style!
eslint          — No issues found
jest            — 34 passed, 34 total (5.2s)

No build step exists for the auth frame (inline HTML is the source). No dist artifacts to commit.


References

…nel sender (ENG-4596)

Fixes two cross-origin vulnerabilities in the auth frame (ENG-4596 / TKA-20260806-016):

## Vuln A — MessageChannel gate accepts any sender

Before this fix the TURNKEY_INIT_MESSAGE_CHANNEL handler gated only on
event.ports?.[0], allowing any window (not just the direct parent) to
establish the privileged MessageChannel.

Fix: mirror the INT-697 export-and-sign pattern. The gate now requires:
  - event.source === window.parent
  - event.origin is truthy and !== 'null'
  - event.ports?.length === 1

## Vuln B — embedded key not scoped to parent origin

Before this fix a single P-256 private key was persisted in localStorage
under the fixed name 'TURNKEY_EMBEDDED_KEY', created before any parent
origin was known. The same key (and PUBLIC_KEY_READY public key) was
served to every embedder, enabling an attacker to replay an auth bundle
from one origin into the same key on another origin.

Fix (mirrors INT-697 export-and-sign approach, applied inline in auth/index.html):
- validateParentOrigin() validates the origin is non-opaque
- initEmbeddedKey(origin) stores the key under a V2 scoped storage key
  'TURNKEY_EMBEDDED_KEY_V2:<encodeURIComponent(origin)>'
- initEphemeralEmbeddedKey() creates a memory-only key for legacy clients
- purgeLegacyEmbeddedKey() removes the old fixed key on every init
- embeddedKeyState tracks mode ('persistent' or 'ephemeral') + origin/key
- getBoundOrigin() exposes the current bound origin for re-init
- Standalone mode (window.parent === window): persists key scoped to
  window.location.origin
- Embedded mode on DOMContentLoaded: creates ephemeral key for legacy
  (@turnkey/iframe-stamper < 2.1.0) clients
- MessageChannel handshake: supersedes ephemeral with persistent key
  scoped to event.origin; rolls back channelEstablished on failure
- Legacy embedded path: binds to first sender's origin; rejects others

Linear: ENG-4596 — https://linear.app/turnkey/issue/ENG-4596
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant