diff --git a/crates/obscura-cdp/tests/structured_clone_crypto_parity.rs b/crates/obscura-cdp/tests/structured_clone_crypto_parity.rs index e234b10..a7301ce 100644 --- a/crates/obscura-cdp/tests/structured_clone_crypto_parity.rs +++ b/crates/obscura-cdp/tests/structured_clone_crypto_parity.rs @@ -207,3 +207,38 @@ async fn structured_clone_rejects_functions_and_symbols() { assert_eq!(val["fn"], "DataCloneError", "functions must not clone"); assert_eq!(val["sym"], "DataCloneError", "symbols must not clone"); } + +// structuredClone preserves reference identity within one graph, including for +// platform objects cloned through a hook (issue #423). The CryptoKey hook +// ignored the `seen` map _structuredClone hands it, so one key referenced twice +// came back as two distinct objects. +#[tokio::test(flavor = "current_thread")] +async fn structured_clone_preserves_cryptokey_identity() { + let (mut ctx, sid) = setup().await; + 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({ a: key, b: key }); + // The shared clone must still be usable by crypto.subtle. + const sig = await crypto.subtle.sign("HMAC", clone.a, new TextEncoder().encode("abc")); + return JSON.stringify({ + shared: clone.a === clone.b, + distinctFromSource: clone.a !== key, + tag: clone.a[Symbol.toStringTag], + sigLen: new Uint8Array(sig).length, + }); + })()"#, + &sid, + ) + .await; + let val = serde_json::from_str::(v["result"]["value"].as_str().unwrap()).unwrap(); + assert_eq!(val["shared"], true, "one CryptoKey reached twice must clone to one shared object"); + assert_eq!(val["distinctFromSource"], true, "clone must not alias the source key"); + assert_eq!(val["tag"], "CryptoKey"); + assert_eq!(val["sigLen"], 32, "the shared clone must remain usable by crypto.subtle"); +} diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 23cefef..4009bed 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -7094,8 +7094,13 @@ if (!globalThis.crypto.subtle) { // _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) { + // `seen` is the clone memo _structuredClone hands every hook. Populate it so + // one key reached twice in a graph clones to one shared object (and its key + // material is registered once), matching structuredClone's identity rules. + globalThis.__obscura_clone_hooks["CryptoKey"] = function (src, seen) { + if (seen && seen.has(src)) return seen.get(src); const copy = makeKey(src.type, src.extractable, src.algorithm, src.usages, keyBytes(src)); + if (seen) seen.set(src, copy); return copy; };