From ed75730e2d8a92ac8594bd8dd69d1e6cb4fb94f9 Mon Sep 17 00:00:00 2001 From: MrBeanDev Date: Wed, 1 Jul 2026 12:11:55 +0530 Subject: [PATCH] feat(js): implement WebCrypto SubtleCrypto secret-key algorithms crypto.subtle only implemented digest. sign returned a fixed 32-byte buffer, verify always returned true, encrypt and decrypt threw, and generateKey, importKey and deriveBits handed back placeholder data. Pages that use WebCrypto (anti-bot probes, PKCE and other auth flows, client-side crypto) silently got wrong results. Implement the secret-key half of WebCrypto on RustCrypto, following the existing op_subtle_digest pattern: - HMAC sign and verify (SHA-1/256/384/512) - AES-GCM, AES-CBC and AES-CTR encrypt and decrypt - PBKDF2 and HKDF deriveBits and deriveKey - raw and symmetric JWK (oct) importKey and exportKey, generateKey, wrapKey and unwrapKey - getRandomValues and randomUUID now draw from the OS CSPRNG instead of Math.random, so they are uniform across typed-array widths and no longer a fingerprinting tell Unsupported algorithms (RSA, ECDSA, ECDH) and key formats (pkcs8, spki) throw NotSupportedError instead of returning fake data. The crypto runs in stateless Rust ops; the bootstrap.js shim only marshals bytes and normalizes algorithm parameters, so the native fast paths are untouched and there is no cost unless a page calls crypto. Verified against RFC 4231 (HMAC), the NIST GCM vectors, NIST SP800-38A (AES-CTR), RFC 6070 (PBKDF2) and RFC 5869 (HKDF), plus roundtrip and tag/padding tamper-rejection checks. --- Cargo.lock | 161 +++++++++++++++ crates/obscura-js/Cargo.toml | 11 ++ crates/obscura-js/js/bootstrap.js | 319 +++++++++++++++++++++++++++--- crates/obscura-js/src/ops.rs | 241 ++++++++++++++++++++++ 4 files changed, 705 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 90a50a8..46c3b52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -240,6 +275,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "brotli" version = "8.0.3" @@ -324,6 +368,15 @@ dependencies = [ "syn", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.64" @@ -357,6 +410,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -472,6 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -498,6 +562,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -643,6 +716,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -957,6 +1031,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.3" @@ -998,6 +1082,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -1263,6 +1365,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1603,13 +1715,21 @@ dependencies = [ name = "obscura-js" version = "0.1.0" dependencies = [ + "aes", + "aes-gcm", "anyhow", "base64", + "cbc", + "ctr", "deno_core", "deno_error", + "getrandom 0.2.17", + "hkdf", + "hmac", "html5ever", "obscura-dom", "obscura-net", + "pbkdf2", "reqwest", "serde", "serde_json", @@ -1666,6 +1786,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-macros" version = "0.1.1" @@ -1712,6 +1838,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1802,6 +1938,18 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1984,6 +2132,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -2872,6 +3023,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/crates/obscura-js/Cargo.toml b/crates/obscura-js/Cargo.toml index 29625cf..0dfa4ff 100644 --- a/crates/obscura-js/Cargo.toml +++ b/crates/obscura-js/Cargo.toml @@ -30,3 +30,14 @@ deno_error = "0.6" base64 = { workspace = true } sha1 = "0.10" sha2 = "0.10" +# WebCrypto (crypto.subtle) primitives. RustCrypto, pure Rust, no CMake/OpenSSL, +# and they unify on the digest 0.10 / cipher 0.4 stack already in the tree. +hmac = "0.12" +aes = "0.8" +aes-gcm = "0.10" +cbc = { version = "0.1", features = ["alloc"] } +ctr = "0.9" +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +hkdf = "0.12" +# CSPRNG for getRandomValues / randomUUID (replaces Math.random). +getrandom = "0.2" diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 9a064cf..882d3ac 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -4835,8 +4835,33 @@ Object.defineProperty(Document.prototype, 'fonts', { configurable: true, }); globalThis.Crypto = class Crypto { - getRandomValues(arr) { for(let i=0;i{const r=Math.random()*16|0;return(c==="x"?r:(r&3|8)).toString(16);}); } + // Fill an integer TypedArray from the OS CSPRNG. Filling the underlying bytes + // (not per-element Math.random) keeps the distribution uniform across every + // typed-array width and is actually cryptographically random. + getRandomValues(arr) { + if (!ArrayBuffer.isView(arr) || arr instanceof DataView || + arr instanceof Float32Array || arr instanceof Float64Array || + (typeof Float16Array !== 'undefined' && arr instanceof Float16Array)) { + throw new DOMException("The provided ArrayBufferView is not an integer-typed array", "TypeMismatchError"); + } + if (arr.byteLength > 65536) { + throw new DOMException("The requested length exceeds 65536 bytes", "QuotaExceededError"); + } + const bytes = Deno.core.ops.op_random_bytes(arr.byteLength); + new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength).set(bytes); + return arr; + } + randomUUID() { + const b = Deno.core.ops.op_random_bytes(16); + b[6] = (b[6] & 0x0f) | 0x40; // version 4 + b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx + let s = ""; + for (let i = 0; i < 16; i++) { + s += (b[i] + 0x100).toString(16).slice(1); + if (i === 3 || i === 5 || i === 7 || i === 9) s += "-"; + } + return s; + } }; globalThis.crypto = globalThis.crypto || new globalThis.Crypto(); globalThis.structuredClone = globalThis.structuredClone || ((v) => JSON.parse(JSON.stringify(v))); @@ -6608,32 +6633,272 @@ if (typeof TransformStream === 'undefined') { if (!globalThis.crypto) globalThis.crypto = {}; if (!globalThis.crypto.subtle) { - globalThis.crypto.subtle = { - async digest(algorithm, data) { - const name = (typeof algorithm === 'string' ? algorithm : algorithm?.name || '').toUpperCase().replace('_', '-'); - if (name !== 'SHA-1' && name !== 'SHA-256' && name !== 'SHA-384' && name !== 'SHA-512' && - name !== 'SHA-512/224' && name !== 'SHA-512/256') { - throw new DOMException('Unrecognized algorithm name', 'NotSupportedError'); - } - let bytes; - if (data instanceof ArrayBuffer) bytes = new Uint8Array(data); - else if (ArrayBuffer.isView(data)) bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - else bytes = new Uint8Array(data || []); - const out = Deno.core.ops.op_subtle_digest(name, bytes); - return new Uint8Array(out).buffer; - }, - async encrypt() { throw new DOMException('NotSupportedError'); }, - async decrypt() { throw new DOMException('NotSupportedError'); }, - async sign() { return new ArrayBuffer(32); }, - async verify() { return true; }, - async generateKey() { return { type: 'secret', algorithm: {}, extractable: false, usages: [] }; }, - async importKey() { return { type: 'secret', algorithm: {}, extractable: false, usages: [] }; }, - async exportKey() { return new ArrayBuffer(32); }, - async deriveBits() { return new ArrayBuffer(32); }, - async deriveKey() { return { type: 'secret', algorithm: {}, extractable: false, usages: [] }; }, - async wrapKey() { return new ArrayBuffer(32); }, - async unwrapKey() { return { type: 'secret', algorithm: {}, extractable: false, usages: [] }; }, + // Real WebCrypto for the secret-key algorithms sites actually use: HMAC, + // AES-GCM/CBC/CTR, PBKDF2 and HKDF, plus raw/JWK-oct key handling. The crypto + // itself runs in Rust ops (RustCrypto); this shim only marshals bytes and + // normalizes algorithm parameters. Public-key algorithms (RSA*, ECDSA, ECDH) + // and non-symmetric key formats (pkcs8/spki) are not implemented and throw + // NotSupportedError rather than returning fake data. + const keyMaterial = new WeakMap(); + + class CryptoKey { + constructor() { throw new TypeError("Illegal constructor"); } + get [Symbol.toStringTag]() { return "CryptoKey"; } + } + function makeKey(type, extractable, algorithm, usages, bytes) { + const k = Object.create(CryptoKey.prototype); + Object.defineProperty(k, "type", { value: type, enumerable: true }); + Object.defineProperty(k, "extractable", { value: !!extractable, enumerable: true }); + Object.defineProperty(k, "algorithm", { value: algorithm, enumerable: true }); + Object.defineProperty(k, "usages", { value: Object.freeze((usages || []).slice()), enumerable: true }); + keyMaterial.set(k, bytes); + return k; + } + function keyBytes(key) { + if (!(key instanceof CryptoKey) || !keyMaterial.has(key)) { + throw new DOMException("Argument is not a valid CryptoKey", "InvalidAccessError"); + } + return keyMaterial.get(key); + } + + const toBytes = (data) => { + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + return new Uint8Array(data || []); }; + const bufferOf = (u8) => new Uint8Array(u8).buffer; + + const ALGO_CANON = { + "AES-CTR": "AES-CTR", "AES-CBC": "AES-CBC", "AES-GCM": "AES-GCM", "AES-KW": "AES-KW", + "HMAC": "HMAC", "PBKDF2": "PBKDF2", "HKDF": "HKDF", + "RSASSA-PKCS1-V1_5": "RSASSA-PKCS1-v1_5", "RSA-PSS": "RSA-PSS", "RSA-OAEP": "RSA-OAEP", + "ECDSA": "ECDSA", "ECDH": "ECDH", + }; + function normalizeAlgo(algorithm) { + const a = typeof algorithm === "string" ? { name: algorithm } : (algorithm || {}); + const upper = String(a.name || "").toUpperCase(); + const name = ALGO_CANON[upper] || upper; + return Object.assign({}, a, { name }); + } + // SubtleCrypto hashes for HMAC/PBKDF2/HKDF and digest (SHA-1/256/384/512). + function normalizeHash(h) { + const n = (typeof h === "string" ? h : (h && h.name) || "").toUpperCase().replace("_", "-"); + if (n === "SHA-1" || n === "SHA-256" || n === "SHA-384" || n === "SHA-512") return n; + throw new DOMException("Unsupported hash algorithm: " + (h && (h.name || h)), "NotSupportedError"); + } + const hashBlockSize = (hash) => (hash === "SHA-384" || hash === "SHA-512" ? 128 : 64); + + function b64urlToBytes(s) { + s = String(s).replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + function bytesToB64url(bytes) { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + // Run an op, converting a Rust-side failure (bad GCM tag, bad CBC padding) + // into the OperationError the WebCrypto spec requires. DOMExceptions we raise + // ourselves pass through unchanged. + function runOp(fn) { + try { return fn(); } + catch (e) { + if (e instanceof DOMException) throw e; + throw new DOMException(String((e && e.message) || e), "OperationError"); + } + } + + function keyAlgorithmFor(alg, bytes) { + switch (alg.name) { + case "HMAC": + return { name: "HMAC", hash: { name: normalizeHash(alg.hash) }, length: bytes.length * 8 }; + case "AES-CTR": case "AES-CBC": case "AES-GCM": case "AES-KW": + if (bytes.length !== 16 && bytes.length !== 24 && bytes.length !== 32) { + throw new DOMException("AES key data must be 128, 192, or 256 bits", "DataError"); + } + return { name: alg.name, length: bytes.length * 8 }; + case "PBKDF2": return { name: "PBKDF2" }; + case "HKDF": return { name: "HKDF" }; + default: + throw new DOMException("Unsupported key algorithm: " + alg.name, "NotSupportedError"); + } + } + + const subtle = { + async digest(algorithm, data) { + const name = (typeof algorithm === "string" ? algorithm : algorithm && algorithm.name || "").toUpperCase().replace("_", "-"); + if (name !== "SHA-1" && name !== "SHA-256" && name !== "SHA-384" && name !== "SHA-512" && + name !== "SHA-512/224" && name !== "SHA-512/256") { + throw new DOMException("Unrecognized algorithm name", "NotSupportedError"); + } + return bufferOf(Deno.core.ops.op_subtle_digest(name, toBytes(data))); + }, + + async importKey(format, keyData, algorithm, extractable, keyUsages) { + const alg = normalizeAlgo(algorithm); + let bytes; + if (format === "raw") { + bytes = toBytes(keyData); + } else if (format === "jwk") { + if (!keyData || keyData.kty !== "oct" || typeof keyData.k !== "string") { + throw new DOMException("Only symmetric 'oct' JWK keys are supported", "NotSupportedError"); + } + bytes = b64urlToBytes(keyData.k); + } else { + throw new DOMException("Only 'raw' and symmetric 'jwk' key formats are supported", "NotSupportedError"); + } + return makeKey("secret", extractable, keyAlgorithmFor(alg, bytes), keyUsages, bytes); + }, + + async exportKey(format, key) { + const bytes = keyBytes(key); + if (!key.extractable) throw new DOMException("Key is not extractable", "InvalidAccessError"); + if (format === "raw") return bufferOf(bytes); + if (format === "jwk") { + const jwk = { kty: "oct", k: bytesToB64url(bytes), ext: key.extractable, key_ops: key.usages.slice() }; + if (key.algorithm.name && key.algorithm.name.indexOf("AES-") === 0) { + jwk.alg = "A" + (bytes.length * 8) + key.algorithm.name.slice(4); + } else if (key.algorithm.name === "HMAC") { + jwk.alg = "HS" + key.algorithm.hash.name.slice(4); + } + return jwk; + } + throw new DOMException("Only 'raw' and 'jwk' export is supported", "NotSupportedError"); + }, + + async generateKey(algorithm, extractable, keyUsages) { + const alg = normalizeAlgo(algorithm); + if (alg.name === "HMAC") { + const hash = normalizeHash(alg.hash); + const len = alg.length ? Math.ceil(alg.length / 8) : hashBlockSize(hash); + const bytes = Deno.core.ops.op_random_bytes(len); + return makeKey("secret", extractable, { name: "HMAC", hash: { name: hash }, length: len * 8 }, keyUsages, bytes); + } + if (alg.name === "AES-CTR" || alg.name === "AES-CBC" || alg.name === "AES-GCM" || alg.name === "AES-KW") { + if (alg.length !== 128 && alg.length !== 192 && alg.length !== 256) { + throw new DOMException("AES key length must be 128, 192, or 256 bits", "OperationError"); + } + const bytes = Deno.core.ops.op_random_bytes(alg.length / 8); + return makeKey("secret", extractable, { name: alg.name, length: alg.length }, keyUsages, bytes); + } + throw new DOMException("generateKey does not support " + alg.name, "NotSupportedError"); + }, + + async sign(algorithm, key, data) { + const alg = normalizeAlgo(algorithm); + const bytes = keyBytes(key); + if (alg.name === "HMAC") { + const hash = key.algorithm && key.algorithm.hash ? key.algorithm.hash.name : normalizeHash(alg.hash); + return bufferOf(runOp(() => Deno.core.ops.op_subtle_hmac(hash, bytes, toBytes(data)))); + } + throw new DOMException("sign does not support " + alg.name, "NotSupportedError"); + }, + + async verify(algorithm, key, signature, data) { + const alg = normalizeAlgo(algorithm); + const bytes = keyBytes(key); + if (alg.name === "HMAC") { + const hash = key.algorithm && key.algorithm.hash ? key.algorithm.hash.name : normalizeHash(alg.hash); + const mac = runOp(() => Deno.core.ops.op_subtle_hmac(hash, bytes, toBytes(data))); + const sig = toBytes(signature); + if (sig.length !== mac.length) return false; + let diff = 0; + for (let i = 0; i < mac.length; i++) diff |= mac[i] ^ sig[i]; + return diff === 0; + } + throw new DOMException("verify does not support " + alg.name, "NotSupportedError"); + }, + + async encrypt(algorithm, key, data) { return aesCipher(true, algorithm, key, data); }, + async decrypt(algorithm, key, data) { return aesCipher(false, algorithm, key, data); }, + + async deriveBits(algorithm, baseKey, length) { + const alg = normalizeAlgo(algorithm); + const bytes = keyBytes(baseKey); + const lenBytes = Math.ceil((length || 0) / 8); + if (alg.name === "PBKDF2") { + const hash = normalizeHash(alg.hash); + const salt = toBytes(alg.salt); + const iterations = alg.iterations >>> 0; + return bufferOf(runOp(() => Deno.core.ops.op_subtle_pbkdf2(hash, bytes, salt, iterations, lenBytes))); + } + if (alg.name === "HKDF") { + const hash = normalizeHash(alg.hash); + const salt = alg.salt != null ? toBytes(alg.salt) : new Uint8Array(0); + const info = alg.info != null ? toBytes(alg.info) : new Uint8Array(0); + return bufferOf(runOp(() => Deno.core.ops.op_subtle_hkdf(hash, bytes, salt, info, lenBytes))); + } + throw new DOMException("deriveBits does not support " + alg.name, "NotSupportedError"); + }, + + async deriveKey(algorithm, baseKey, derivedKeyAlgorithm, extractable, keyUsages) { + const dAlg = normalizeAlgo(derivedKeyAlgorithm); + let bits; + if (dAlg.name === "HMAC") { + bits = dAlg.length || hashBlockSize(normalizeHash(dAlg.hash)) * 8; + } else if (dAlg.name === "AES-CTR" || dAlg.name === "AES-CBC" || dAlg.name === "AES-GCM" || dAlg.name === "AES-KW") { + bits = dAlg.length; + if (bits !== 128 && bits !== 192 && bits !== 256) { + throw new DOMException("AES key length must be 128, 192, or 256 bits", "OperationError"); + } + } else { + throw new DOMException("deriveKey does not support deriving " + dAlg.name, "NotSupportedError"); + } + const derivedBits = await this.deriveBits(algorithm, baseKey, bits); + return this.importKey("raw", derivedBits, derivedKeyAlgorithm, extractable, keyUsages); + }, + + async wrapKey(format, key, wrappingKey, wrapAlgorithm) { + const exported = await this.exportKey(format, key); + const bytes = format === "jwk" + ? new TextEncoder().encode(JSON.stringify(exported)) + : new Uint8Array(exported); + return this.encrypt(wrapAlgorithm, wrappingKey, bytes); + }, + + async unwrapKey(format, wrappedKey, unwrappingKey, unwrapAlgorithm, unwrappedKeyAlgorithm, extractable, keyUsages) { + const decrypted = await this.decrypt(unwrapAlgorithm, unwrappingKey, wrappedKey); + const keyData = format === "jwk" + ? JSON.parse(new TextDecoder().decode(new Uint8Array(decrypted))) + : decrypted; + return this.importKey(format, keyData, unwrappedKeyAlgorithm, extractable, keyUsages); + }, + }; + + function aesCipher(encrypt, algorithm, key, data) { + const alg = normalizeAlgo(algorithm); + const bytes = keyBytes(key); + const input = toBytes(data); + if (alg.name === "AES-GCM") { + const iv = toBytes(alg.iv); + const aad = alg.additionalData != null ? toBytes(alg.additionalData) : new Uint8Array(0); + const tagLength = alg.tagLength == null ? 128 : alg.tagLength; + if (tagLength !== 128) { + throw new DOMException("Only a 128-bit AES-GCM tag length is supported", "NotSupportedError"); + } + return bufferOf(runOp(() => Deno.core.ops.op_subtle_aes_gcm(encrypt, bytes, iv, aad, input))); + } + if (alg.name === "AES-CBC") { + const iv = toBytes(alg.iv); + return bufferOf(runOp(() => Deno.core.ops.op_subtle_aes_cbc(encrypt, bytes, iv, input))); + } + if (alg.name === "AES-CTR") { + const counter = toBytes(alg.counter); + const length = alg.length >>> 0; + return bufferOf(runOp(() => Deno.core.ops.op_subtle_aes_ctr(bytes, counter, length, input))); + } + throw new DOMException((encrypt ? "encrypt" : "decrypt") + " does not support " + alg.name, "NotSupportedError"); + } + + globalThis.CryptoKey = CryptoKey; + globalThis.SubtleCrypto = function SubtleCrypto() { throw new TypeError("Illegal constructor"); }; + Object.setPrototypeOf(subtle, globalThis.SubtleCrypto.prototype); + globalThis.crypto.subtle = subtle; } if (typeof DOMRect === 'undefined') { diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index c7823d6..79e4ea1 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -1383,6 +1383,240 @@ fn op_subtle_digest(#[string] algorithm: &str, #[buffer] data: &[u8]) -> Vec } } +// --------------------------------------------------------------------------- +// WebCrypto (crypto.subtle) secret-key primitives. +// +// These ops are stateless. The JS shim in bootstrap.js owns the CryptoKey +// objects and their raw key bytes; it hands the bytes plus normalized algorithm +// parameters to these ops for each operation. Only secret-key algorithms live +// here (HMAC, AES-GCM/CBC/CTR, PBKDF2, HKDF); public-key algorithms are rejected +// in the shim. A fallible op returns a JsErrorBox that the shim turns into the +// appropriate DOMException (OperationError for a bad tag or padding, etc.). +// --------------------------------------------------------------------------- + +fn crypto_err(msg: impl std::fmt::Display) -> deno_error::JsErrorBox { + deno_error::JsErrorBox::generic(msg.to_string()) +} + +/// HMAC sign. `hash` is a normalized SubtleCrypto hash name; any key length is +/// accepted (HMAC pads or hashes the key per RFC 2104). Returns the MAC bytes; +/// the shim does the constant-time-insensitive compare for `verify`. +#[op2] +#[buffer] +fn op_subtle_hmac( + #[string] hash: &str, + #[buffer] key: &[u8], + #[buffer] data: &[u8], +) -> Result, deno_error::JsErrorBox> { + use hmac::{Hmac, Mac}; + macro_rules! run { + ($d:ty) => {{ + let mut mac = Hmac::<$d>::new_from_slice(key).map_err(crypto_err)?; + mac.update(data); + mac.finalize().into_bytes().to_vec() + }}; + } + Ok(match hash { + "SHA-1" => run!(sha1::Sha1), + "SHA-256" => run!(sha2::Sha256), + "SHA-384" => run!(sha2::Sha384), + "SHA-512" => run!(sha2::Sha512), + _ => return Err(crypto_err("unsupported HMAC hash")), + }) +} + +/// AES-GCM encrypt/decrypt. WebCrypto's ciphertext carries the auth tag +/// appended, which is exactly RustCrypto's combined form, so this maps 1:1. +/// Restricted to a 96-bit IV and 128-bit tag (the WebCrypto defaults and the +/// overwhelming majority of real usage); the shim rejects other tag lengths. +#[op2] +#[buffer] +fn op_subtle_aes_gcm( + encrypt: bool, + #[buffer] key: &[u8], + #[buffer] iv: &[u8], + #[buffer] aad: &[u8], + #[buffer] data: &[u8], +) -> Result, deno_error::JsErrorBox> { + use aes_gcm::aead::{Aead, KeyInit, Payload}; + use aes_gcm::aes::{Aes192, Aes256}; + use aes_gcm::{AesGcm, Nonce}; + type Aes192Gcm = AesGcm; + type Aes256Gcm = AesGcm; + + if iv.len() != 12 { + return Err(crypto_err("AES-GCM requires a 96-bit (12-byte) IV")); + } + let nonce = Nonce::from_slice(iv); + macro_rules! run { + ($ty:ty) => {{ + let cipher = <$ty>::new_from_slice(key).map_err(crypto_err)?; + if encrypt { + cipher + .encrypt(nonce, Payload { msg: data, aad }) + .map_err(|_| crypto_err("AES-GCM encryption failed"))? + } else { + cipher + .decrypt(nonce, Payload { msg: data, aad }) + .map_err(|_| crypto_err("AES-GCM decryption failed: authentication tag mismatch"))? + } + }}; + } + Ok(match key.len() { + 16 => run!(aes_gcm::Aes128Gcm), + 24 => run!(Aes192Gcm), + 32 => run!(Aes256Gcm), + _ => return Err(crypto_err("AES-GCM key must be 128, 192, or 256 bits")), + }) +} + +/// AES-CBC encrypt/decrypt with PKCS#7 padding (the only padding WebCrypto +/// AES-CBC uses) and a 16-byte IV. +#[op2] +#[buffer] +fn op_subtle_aes_cbc( + encrypt: bool, + #[buffer] key: &[u8], + #[buffer] iv: &[u8], + #[buffer] data: &[u8], +) -> Result, deno_error::JsErrorBox> { + use cbc::cipher::block_padding::Pkcs7; + use cbc::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit}; + use cbc::{Decryptor, Encryptor}; + + if iv.len() != 16 { + return Err(crypto_err("AES-CBC requires a 16-byte IV")); + } + macro_rules! run { + ($cipher:ty) => {{ + if encrypt { + Encryptor::<$cipher>::new_from_slices(key, iv) + .map_err(crypto_err)? + .encrypt_padded_vec_mut::(data) + } else { + Decryptor::<$cipher>::new_from_slices(key, iv) + .map_err(crypto_err)? + .decrypt_padded_vec_mut::(data) + .map_err(|_| crypto_err("AES-CBC decryption failed: invalid padding"))? + } + }}; + } + Ok(match key.len() { + 16 => run!(aes::Aes128), + 24 => run!(aes::Aes192), + 32 => run!(aes::Aes256), + _ => return Err(crypto_err("AES-CBC key must be 128, 192, or 256 bits")), + }) +} + +/// AES-CTR. Encrypt and decrypt are the same keystream XOR. `counter_length` is +/// the WebCrypto counter width in bits; it selects the RustCrypto CTR flavor so +/// only the low `counter_length` bits of the 16-byte block increment. +#[op2] +#[buffer] +fn op_subtle_aes_ctr( + #[buffer] key: &[u8], + #[buffer] counter: &[u8], + counter_length: u32, + #[buffer] data: &[u8], +) -> Result, deno_error::JsErrorBox> { + use ctr::cipher::{KeyIvInit, StreamCipher}; + + if counter.len() != 16 { + return Err(crypto_err("AES-CTR requires a 16-byte counter block")); + } + let mut buf = data.to_vec(); + macro_rules! run { + ($ty:ty) => {{ + <$ty>::new_from_slices(key, counter) + .map_err(crypto_err)? + .apply_keystream(&mut buf); + }}; + } + macro_rules! by_key { + ($flavor:ident) => { + match key.len() { + 16 => run!(ctr::$flavor), + 24 => run!(ctr::$flavor), + 32 => run!(ctr::$flavor), + _ => return Err(crypto_err("AES-CTR key must be 128, 192, or 256 bits")), + } + }; + } + match counter_length { + 128 => by_key!(Ctr128BE), + 64 => by_key!(Ctr64BE), + 32 => by_key!(Ctr32BE), + _ => return Err(crypto_err("AES-CTR supports counter lengths of 32, 64, or 128 bits")), + } + Ok(buf) +} + +/// PBKDF2 key derivation. `length` is the derived-bits output in bytes. +#[op2] +#[buffer] +fn op_subtle_pbkdf2( + #[string] hash: &str, + #[buffer] password: &[u8], + #[buffer] salt: &[u8], + iterations: u32, + length: u32, +) -> Result, deno_error::JsErrorBox> { + use pbkdf2::pbkdf2_hmac; + let mut dk = vec![0u8; length as usize]; + match hash { + "SHA-1" => pbkdf2_hmac::(password, salt, iterations, &mut dk), + "SHA-256" => pbkdf2_hmac::(password, salt, iterations, &mut dk), + "SHA-384" => pbkdf2_hmac::(password, salt, iterations, &mut dk), + "SHA-512" => pbkdf2_hmac::(password, salt, iterations, &mut dk), + _ => return Err(crypto_err("unsupported PBKDF2 hash")), + } + Ok(dk) +} + +/// HKDF key derivation. `length` is the output length in bytes. An empty salt +/// behaves as RFC 5869 specifies (HMAC zero-pads it to the block size, which is +/// what browsers do). +#[op2] +#[buffer] +fn op_subtle_hkdf( + #[string] hash: &str, + #[buffer] ikm: &[u8], + #[buffer] salt: &[u8], + #[buffer] info: &[u8], + length: u32, +) -> Result, deno_error::JsErrorBox> { + use hkdf::Hkdf; + let mut okm = vec![0u8; length as usize]; + macro_rules! run { + ($d:ty) => { + Hkdf::<$d>::new(Some(salt), ikm) + .expand(info, &mut okm) + .map_err(|_| crypto_err("HKDF: requested key length is too long"))? + }; + } + match hash { + "SHA-1" => run!(sha1::Sha1), + "SHA-256" => run!(sha2::Sha256), + "SHA-384" => run!(sha2::Sha384), + "SHA-512" => run!(sha2::Sha512), + _ => return Err(crypto_err("unsupported HKDF hash")), + } + Ok(okm) +} + +/// Fill `len` bytes from the OS CSPRNG. Backs `crypto.getRandomValues`, +/// `crypto.randomUUID`, and `generateKey`, replacing the old Math.random shim +/// (which was neither uniform across typed-array widths nor cryptographically +/// random, and was a fingerprinting tell). +#[op2] +#[buffer] +fn op_random_bytes(len: u32) -> Result, deno_error::JsErrorBox> { + let mut buf = vec![0u8; len as usize]; + getrandom::getrandom(&mut buf).map_err(|e| crypto_err(format!("getrandom failed: {e}")))?; + Ok(buf) +} + /// Serialize a parsed URL into the WHATWG IDL component shape consumed by the /// `URL` class in bootstrap.js. Getters read these fields directly so no op /// call happens per property access. @@ -1600,6 +1834,13 @@ pub fn build_extension() -> Extension { op_sleep(), op_binding_called(), op_subtle_digest(), + op_subtle_hmac(), + op_subtle_aes_gcm(), + op_subtle_aes_cbc(), + op_subtle_aes_ctr(), + op_subtle_pbkdf2(), + op_subtle_hkdf(), + op_random_bytes(), op_url_parse(), op_url_set(), op_url_resolve(),