diff --git a/crates/obscura-cdp/tests/structured_clone_crypto_parity.rs b/crates/obscura-cdp/tests/structured_clone_crypto_parity.rs
new file mode 100644
index 0000000..e234b10
--- /dev/null
+++ b/crates/obscura-cdp/tests/structured_clone_crypto_parity.rs
@@ -0,0 +1,209 @@
+// Regression parity for issue #389: Cloudflare managed challenges hang because
+// bootstrap.js stubbed two structured-clone primitives the turnstile
+// orchestrate VM depends on. Each case below fails on main and must pass after
+// the fix:
+//
+// 1. `structuredClone` must preserve ArrayBuffer / TypedArray bytes (the
+// JSON fallback on line 5123 serializes them to `{}`).
+// 2. A `CryptoKey` must survive `structuredClone` and remain usable by
+// `crypto.subtle` (the WeakMap on line 6898 is keyed by object identity,
+// so a clone has no key material and throws "not a valid CryptoKey").
+//
+// These mirror the cdp_click_submit_parity helpers (`serve_once` / `cdp`),
+// copied per the Testing-and-debugging.md guidance to reuse the pattern.
+
+use obscura_cdp::dispatch::{dispatch, CdpContext};
+use obscura_cdp::types::CdpRequest;
+use serde_json::{json, Value};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpListener;
+
+async fn serve_once() -> String {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ tokio::spawn(async move {
+ let (mut socket, _) = listener.accept().await.unwrap();
+ tokio::spawn(async move {
+ let mut buf = [0u8; 2048];
+ let _ = socket.read(&mut buf).await.unwrap();
+ let body = "
";
+ let resp = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ );
+ let _ = socket.write_all(resp.as_bytes()).await;
+ });
+ });
+ format!("http://{addr}/")
+}
+
+async fn cdp(
+ ctx: &mut CdpContext,
+ id: u64,
+ method: &str,
+ params: Value,
+ session_id: &str,
+) -> Value {
+ let resp = dispatch(
+ &CdpRequest {
+ id,
+ method: method.to_string(),
+ params,
+ session_id: Some(session_id.to_string()),
+ },
+ ctx,
+ )
+ .await;
+ assert!(
+ resp.error.is_none(),
+ "CDP {method} failed: {:?}",
+ resp.error
+ );
+ resp.result.unwrap_or_else(|| json!({}))
+}
+
+async fn eval(ctx: &mut CdpContext, id: u64, expr: &str, session_id: &str) -> Value {
+ cdp(
+ ctx,
+ id,
+ "Runtime.evaluate",
+ json!({"expression": expr, "returnByValue": true, "awaitPromise": true}),
+ session_id,
+ )
+ .await
+}
+
+async fn setup() -> (CdpContext, String) {
+ std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
+ let url = serve_once().await;
+ let mut ctx = CdpContext::new();
+ let page_id = ctx.create_page();
+ let session_id = "session-1";
+ ctx.sessions.insert(session_id.to_string(), page_id.clone());
+ cdp(
+ &mut ctx,
+ 1,
+ "Page.navigate",
+ json!({"url": url, "waitUntil": "load"}),
+ session_id,
+ )
+ .await;
+ (ctx, session_id.to_string())
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn structured_clone_preserves_arraybuffer_bytes() {
+ let (mut ctx, sid) = setup().await;
+ // A 4-byte view into a 4-byte buffer. The JSON fallback loses the buffer
+ // entirely (Uint8Array serializes to {}), so byteLength reads back as 0.
+ let v = eval(
+ &mut ctx,
+ 2,
+ r#"(async () => {
+ const src = new Uint8Array([10, 20, 30, 40]);
+ const clone = structuredClone(src);
+ return JSON.stringify({
+ srcLen: src.byteLength,
+ cloneLen: clone.byteLength,
+ same: src.buffer === clone.buffer,
+ bytes: Array.from(clone),
+ });
+ })()"#,
+ &sid,
+ )
+ .await;
+ let val = serde_json::from_str::(v["result"]["value"].as_str().unwrap()).unwrap();
+ assert_eq!(val["srcLen"], 4);
+ assert_eq!(val["cloneLen"], 4, "structuredClone dropped the ArrayBuffer");
+ assert_eq!(val["same"], false, "clone must be independent, not the same buffer");
+ assert_eq!(val["bytes"], json!([10, 20, 30, 40]));
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn cryptokey_survives_structured_clone_and_still_signs() {
+ let (mut ctx, sid) = setup().await;
+ // importKey -> structuredClone -> sign with the clone. On main the clone
+ // has no WeakMap entry, so sign throws "Argument is not a valid CryptoKey".
+ let v = eval(
+ &mut ctx,
+ 2,
+ r#"(async () => {
+ const key = await crypto.subtle.importKey(
+ "raw", new Uint8Array(32),
+ { name: "HMAC", hash: "SHA-256" }, true, ["sign"]
+ );
+ const clone = structuredClone(key);
+ const sig = await crypto.subtle.sign("HMAC", clone, new TextEncoder().encode("abc"));
+ const b = new Uint8Array(sig);
+ return JSON.stringify({
+ cloneType: clone.type,
+ cloneTag: clone[Symbol.toStringTag],
+ sigLen: b.length,
+ });
+ })()"#,
+ &sid,
+ )
+ .await;
+ let val = serde_json::from_str::(v["result"]["value"].as_str().unwrap()).unwrap();
+ assert_eq!(val["cloneType"], "secret");
+ assert_eq!(val["cloneTag"], "CryptoKey");
+ assert_eq!(val["sigLen"], 32, "cloned CryptoKey must remain usable by crypto.subtle");
+}
+
+// DataView has no .slice() method, so the original TypedArray branch
+// (`new Ctor(value.slice())`) threw `TypeError: value.slice is not a function`
+// on every DataView clone. That is a new crash in the very buffers category
+// this feature targets, so it must clone cleanly.
+#[tokio::test(flavor = "current_thread")]
+async fn structured_clone_preserves_dataview() {
+ let (mut ctx, sid) = setup().await;
+ let v = eval(
+ &mut ctx,
+ 2,
+ r#"(async () => {
+ const buf = new ArrayBuffer(8);
+ const view = new DataView(buf);
+ view.setUint32(0, 0x12345678);
+ view.setUint32(4, 0x9abcdef0);
+ const clone = structuredClone(view);
+ return JSON.stringify({
+ len: clone.byteLength,
+ a: clone.getUint32(0),
+ b: clone.getUint32(4),
+ independent: clone.buffer !== view.buffer,
+ });
+ })()"#,
+ &sid,
+ )
+ .await;
+ let val = serde_json::from_str::(v["result"]["value"].as_str().unwrap()).unwrap();
+ assert_eq!(val["len"], 8, "DataView clone must keep its length");
+ assert_eq!(val["a"], 0x12345678);
+ assert_eq!(val["b"], 0x9abcdef0u64 as i64);
+ assert_eq!(val["independent"], true, "clone must own its buffer, not alias the source");
+}
+
+// Functions and symbols are not structured-cloneable. The original early
+// `typeof !== "object"` return passed them through by reference instead of
+// throwing DataCloneError, so this guards both the throw and the name.
+#[tokio::test(flavor = "current_thread")]
+async fn structured_clone_rejects_functions_and_symbols() {
+ let (mut ctx, sid) = setup().await;
+ let v = eval(
+ &mut ctx,
+ 2,
+ r#"(async () => {
+ const out = {};
+ try { structuredClone(function f(){}); out.fn = "cloned"; }
+ catch (e) { out.fn = e instanceof DOMException ? e.name : "TypeError:" + e.message; }
+ try { structuredClone(Symbol("s")); out.sym = "cloned"; }
+ catch (e) { out.sym = e instanceof DOMException ? e.name : "TypeError:" + e.message; }
+ return JSON.stringify(out);
+ })()"#,
+ &sid,
+ )
+ .await;
+ let val = serde_json::from_str::(v["result"]["value"].as_str().unwrap()).unwrap();
+ assert_eq!(val["fn"], "DataCloneError", "functions must not clone");
+ assert_eq!(val["sym"], "DataCloneError", "symbols must not clone");
+}
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index 015adad..0625061 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -5164,7 +5164,88 @@ globalThis.Crypto = class Crypto {
}
};
globalThis.crypto = globalThis.crypto || new globalThis.Crypto();
-globalThis.structuredClone = globalThis.structuredClone || ((v) => JSON.parse(JSON.stringify(v)));
+// Real structured clone (not JSON). JSON.parse(JSON.stringify) silently drops
+// ArrayBuffer/TypedArray (they serialize to {}), so Cloudflare's turnstile
+// orchestrate loses every byte it tries to round-trip through postMessage and
+// the challenge never completes (issue #389). Clone buffers, typed arrays,
+// maps/sets, dates, errors, and plain objects recursively; CryptoKey and other
+// types that register a clone hook (see crypto.subtle below) are routed there.
+function _structuredClone(value, seen) {
+ // Functions and symbols are not structured-cloneable (HTML structured clone,
+ // DataCloneError). This must run before the primitive early-return below,
+ // which would otherwise pass them through by reference.
+ if (typeof value === "function" || typeof value === "symbol") {
+ throw new DOMException("Failed to execute 'structuredClone': value could not be cloned.", "DataCloneError");
+ }
+ if (value === null || typeof value !== "object") return value;
+ if (seen.has(value)) return seen.get(value);
+ // Typed arrays: copy the underlying buffer slice. DataView has no .slice(),
+ // so slice its buffer over the view's range and wrap a fresh view.
+ if (ArrayBuffer.isView(value)) {
+ if (value instanceof DataView) {
+ const buf = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
+ const copy = new DataView(buf);
+ seen.set(value, copy);
+ return copy;
+ }
+ const Ctor = value.constructor;
+ const copy = new Ctor(value.slice());
+ seen.set(value, copy);
+ return copy;
+ }
+ if (value instanceof ArrayBuffer) {
+ const copy = value.slice(0);
+ seen.set(value, copy);
+ return copy;
+ }
+ if (value instanceof SharedArrayBuffer) {
+ return value; // transferable, not copyable
+ }
+ if (value instanceof Date) return new Date(value.getTime());
+ if (value instanceof RegExp) return new RegExp(value.source, value.flags);
+ if (value instanceof Map) {
+ const m = new Map();
+ seen.set(value, m);
+ for (const [k, v] of value) m.set(_structuredClone(k, seen), _structuredClone(v, seen));
+ return m;
+ }
+ if (value instanceof Set) {
+ const s = new Set();
+ seen.set(value, s);
+ for (const v of value) s.add(_structuredClone(v, seen));
+ return s;
+ }
+ if (value instanceof Error) {
+ const Ctor = value.constructor || Error;
+ const e = new Ctor(value.message);
+ if (value.name) e.name = value.name;
+ if (value.stack) e.stack = value.stack;
+ if (value.cause !== undefined) e.cause = _structuredClone(value.cause, seen);
+ return e;
+ }
+ // Platform objects that carry internal slots opt into cloning via a hook
+ // (CryptoKey re-registers its key material so the clone stays usable by
+ // crypto.subtle). Anything else with a registered hook takes that path.
+ if (typeof value[Symbol.toStringTag] === "string" && globalThis.__obscura_clone_hooks) {
+ const hook = globalThis.__obscura_clone_hooks[value[Symbol.toStringTag]];
+ if (typeof hook === "function") return hook(value, seen);
+ }
+ const out = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
+ seen.set(value, out);
+ for (const k in value) {
+ if (Object.prototype.hasOwnProperty.call(value, k)) {
+ out[k] = _structuredClone(value[k], seen);
+ }
+ }
+ // Symbols are not enumerable via for-in; copy own symbol-keyed properties.
+ const syms = Object.getOwnPropertySymbols(value);
+ for (const s of syms) {
+ const d = Object.getOwnPropertyDescriptor(value, s);
+ if (d && "value" in d) out[s] = _structuredClone(d.value, seen);
+ }
+ return out;
+}
+globalThis.structuredClone = globalThis.structuredClone || ((v) => _structuredClone(v, new Map()));
globalThis.reportError = globalThis.reportError || ((e) => console.error(e));
// WHATWG Storage as a legacy platform object: a Proxy routes property access
@@ -6958,6 +7039,17 @@ if (!globalThis.crypto.subtle) {
}
return keyMaterial.get(key);
}
+ // A CryptoKey cloned via structuredClone or postMessage is a different
+ // object, so the WeakMap lookup above misses and crypto.subtle throws
+ // "Argument is not a valid CryptoKey". Re-register the (cloned) key's
+ // material so the clone stays usable. The clone hook is dispatched by
+ // _structuredClone via Symbol.toStringTag ("CryptoKey"); registered lazily
+ // because structuredClone is defined before this block (issue #389).
+ globalThis.__obscura_clone_hooks = globalThis.__obscura_clone_hooks || {};
+ globalThis.__obscura_clone_hooks["CryptoKey"] = function (src) {
+ const copy = makeKey(src.type, src.extractable, src.algorithm, src.usages, keyBytes(src));
+ return copy;
+ };
const toBytes = (data) => {
if (data instanceof ArrayBuffer) return new Uint8Array(data);