diff --git a/Cargo.lock b/Cargo.lock index 88341a78..228861b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -883,6 +883,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1276,6 +1277,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[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.9" @@ -1568,15 +1578,18 @@ dependencies = [ "anyhow", "bitflags", "fastrand", + "hmac", "javy-test-macros", "quickcheck", "rmp-serde", "rquickjs", "rquickjs-core", + "rquickjs-macro", "rquickjs-sys", "serde", "serde-transcode", "serde_json", + "sha2", "simd-json", ] @@ -2862,6 +2875,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swc_atoms" version = "0.6.7" diff --git a/Makefile b/Makefile index 8bd7045f..efd39813 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ docs: cargo doc --package=javy-core --open --target=wasm32-wasi test-javy: - CARGO_TARGET_WASM32_WASI_RUNNER="wasmtime --dir=." cargo wasi test --package=javy --features json,messagepack -- --nocapture + CARGO_TARGET_WASM32_WASI_RUNNER="wasmtime --dir=." cargo wasi test --package=javy --features json,messagepack,crypto -- --nocapture test-core: cargo wasi test --package=javy-core -- --nocapture @@ -37,7 +37,7 @@ test-runner: cargo test --package=javy-runner -- --nocapture # WPT requires a Javy build with the experimental_event_loop feature to pass -test-wpt: export CORE_FEATURES ?= experimental_event_loop +test-wpt: export CORE_FEATURES ?= experimental_event_loop,crypto test-wpt: # Can't use a prerequisite here b/c a prequisite will not cause a rebuild of the CLI $(MAKE) cli diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 8836b9e2..2fdc6133 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -31,6 +31,7 @@ bitflags! { const JAVY_STREAM_IO = 1 << 2; const REDIRECT_STDOUT_TO_STDERR = 1 << 3; const TEXT_ENCODING = 1 << 4; + const CRYPTO = 1 << 5; } } @@ -44,5 +45,6 @@ mod tests { assert!(Config::JAVY_STREAM_IO == Config::from_bits(1 << 2).unwrap()); assert!(Config::REDIRECT_STDOUT_TO_STDERR == Config::from_bits(1 << 3).unwrap()); assert!(Config::TEXT_ENCODING == Config::from_bits(1 << 4).unwrap()); + assert!(Config::CRYPTO == Config::from_bits(1 << 5).unwrap()); } } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 0c122095..ac4f370a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -21,3 +21,4 @@ javy-config = { workspace = true } [features] experimental_event_loop = [] +crypto = ["experimental_event_loop", "javy/crypto"] diff --git a/crates/core/src/runtime.rs b/crates/core/src/runtime.rs index b973bd7b..e6725d3f 100644 --- a/crates/core/src/runtime.rs +++ b/crates/core/src/runtime.rs @@ -4,7 +4,7 @@ use javy_config::Config as SharedConfig; pub(crate) fn new(shared_config: SharedConfig) -> Result { let mut config = Config::default(); - let config = config + config .text_encoding(shared_config.contains(SharedConfig::TEXT_ENCODING)) .redirect_stdout_to_stderr(shared_config.contains(SharedConfig::REDIRECT_STDOUT_TO_STDERR)) .javy_stream_io(shared_config.contains(SharedConfig::JAVY_STREAM_IO)) @@ -14,5 +14,8 @@ pub(crate) fn new(shared_config: SharedConfig) -> Result { .override_json_parse_and_stringify(false) .javy_json(false); - Runtime::new(std::mem::take(config)) + #[cfg(feature = "crypto")] + config.crypto(shared_config.contains(SharedConfig::CRYPTO)); + + Runtime::new(std::mem::take(&mut config)) } diff --git a/crates/javy/Cargo.toml b/crates/javy/Cargo.toml index 3bbe2e94..9ad6696f 100644 --- a/crates/javy/Cargo.toml +++ b/crates/javy/Cargo.toml @@ -13,6 +13,7 @@ categories = ["wasm"] anyhow = { workspace = true } rquickjs = { version = "=0.6.1", features = ["array-buffer", "bindgen"] } rquickjs-core = "=0.6.1" +rquickjs-macro = "=0.6.1" rquickjs-sys = "=0.6.1" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", optional = true } @@ -24,6 +25,8 @@ quickcheck = "1" bitflags = { workspace = true } fastrand = "2.1.0" simd-json = { version = "0.13.10", optional = true, default-features = false, features = ["big-int-as-float", "serde_impl"] } +sha2 = { version = "0.10.8", optional = true } +hmac = { version = "0.12.1", optional = true } [dev-dependencies] javy-test-macros = { path = "../test-macros/" } @@ -39,3 +42,5 @@ messagepack = ["rmp-serde", "serde-transcode"] # implications of enabling by default (due to the extra dependencies) and also # because the native implementation is probably fine for most use-cases. json = ["serde_json", "serde-transcode", "simd-json"] +# Enable support for WinterCG-compatible Crypto APIs +crypto = ["dep:sha2", "dep:hmac"] diff --git a/crates/javy/src/apis/crypto/crypto.js b/crates/javy/src/apis/crypto/crypto.js new file mode 100644 index 00000000..002640b1 --- /dev/null +++ b/crates/javy/src/apis/crypto/crypto.js @@ -0,0 +1,17 @@ +(function() { + const __javy_cryptoSubtleSign = globalThis.__javy_cryptoSubtleSign; + + const crypto = { + subtle: {} + }; + + + crypto.subtle.sign = function(obj, key, msg) { + return new Promise((resolve, _) => { + resolve(__javy_cryptoSubtleSign(obj, key, msg)); + }); + } + + globalThis.crypto = crypto; + // Reflect.deleteProperty(globalThis, "__javy_cryptoSubtleSign"); +})(); diff --git a/crates/javy/src/apis/crypto/mod.rs b/crates/javy/src/apis/crypto/mod.rs new file mode 100644 index 00000000..3fd03956 --- /dev/null +++ b/crates/javy/src/apis/crypto/mod.rs @@ -0,0 +1,251 @@ +use crate::quickjs::{ + context::{EvalOptions, Intrinsic}, + qjs, Ctx, Function, String as JSString, Value, +}; +use crate::{hold, hold_and_release, to_js_error, val_to_string, Args}; +use anyhow::{bail, Error, Result}; + +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +/// A Winter CG compatible implementation of the Crypto API. +/// Currently, the following methods are implemented: +/// * `crypto.subtle.sign`, with HMAC sha256 +pub struct Crypto; + +impl Intrinsic for Crypto { + unsafe fn add_intrinsic(ctx: std::ptr::NonNull) { + register(Ctx::from_raw(ctx)).expect("`Crypto` APIs to succeed") + } +} + +fn register(this: Ctx<'_>) -> Result<()> { + let globals = this.globals(); + + globals.set( + "__javy_cryptoSubtleSign", + Function::new(this.clone(), |this, args| { + let (this, args) = hold_and_release!(this, args); + hmac_sha256(hold!(this.clone(), args)).map_err(|e| to_js_error(this, e)) + }), + )?; + let mut opts = EvalOptions::default(); + opts.strict = false; + this.eval_with_options(include_str!("crypto.js"), opts)?; + + Ok::<_, Error>(()) +} + +/// hmac_sha256 applies the HMAC algorithm using sha256 for hashing. +/// Arg[0] - secret +/// Arg[1] - message +/// returns - hex encoded string of hmac. +fn hmac_sha256(args: Args<'_>) -> Result> { + let (ctx, args) = args.release(); + + if args.len() != 3 { + bail!("Wrong number of arguments. Expected 3. Got {}", args.len()); + } + + let protocol = args[0].as_object(); + + let js_protocol_name: Value = protocol.expect("protocol struct required").get("name")?; + if val_to_string(&ctx, js_protocol_name.clone())? != "HMAC" { + bail!("only name=HMAC supported"); + } + + let js_protocol_name: Value = protocol.expect("protocol struct required").get("hash")?; + if val_to_string(&ctx, js_protocol_name.clone())? != "sha-256" { + bail!("only hash=sha-256 supported"); + } + let secret = val_to_string(&ctx, args[1].clone())?; + let message = val_to_string(&ctx, args[2].clone())?; + + let string_digest = hmac_sha256_result(secret, message)?; + let result = JSString::from_str(ctx.clone(), &string_digest)?; + Ok(result.into()) +} + +/// hmac_sha256_result applies the HMAC sha256 algorithm for signing. +fn hmac_sha256_result(secret: String, message: String) -> Result { + type HmacSha256 = Hmac; + let mut hmac = + HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); + hmac.update(message.as_bytes()); + let result = hmac.finalize(); + let code_bytes = result.into_bytes(); + let code: String = format!("{code_bytes:x}"); + Ok(code) +} + +#[cfg(test)] +mod tests { + use crate::{ + from_js_error, quickjs::Ctx, quickjs::Error as JSError, quickjs::Value, val_to_string, + Config, Runtime, + }; + use anyhow::{bail, Error, Result}; + + fn extract_promise_to_string<'a>(ctx: Ctx<'a>, value: Value<'a>) -> Result { + match value.as_promise() { + Some(promise) => { + let resolved = promise.finish::(); + if let Err(JSError::WouldBlock) = resolved { + bail!("unexpected JSError::WouldBlock"); + } else { + Ok(val_to_string(&ctx, resolved.unwrap()).unwrap()) + } + } + None => { + bail!("Expected promise"); + } + } + } + + fn extract_promise_to_err<'a>(ctx: Ctx<'a>, value: Value<'a>) -> Result { + match value.as_promise() { + Some(promise) => { + let resolved = promise.finish::(); + if let Err(JSError::WouldBlock) = resolved { + bail!("unexpected JSError::WouldBlock"); + } else { + assert!(resolved.is_err()); + let e = resolved + .map_err(|e| from_js_error(ctx.clone(), e)) + .unwrap_err(); + Ok(e) + } + } + None => { + bail!("Expected promise"); + } + } + } + + #[test] + fn test_crypto_digest() -> Result<()> { + let mut config = Config::default(); + config.crypto(true); + let runtime = Runtime::new(config)?; + + runtime.context().with(|this| { + let value = this.eval::, _>( + r#"crypto.subtle.sign({name: "HMAC", hash: "sha-256"}, "my secret and secure key", "input message");"#, + ); + let result = extract_promise_to_string(this.clone(), value.unwrap().clone()); + assert_eq!(result?, "97d2a569059bbcd8ead4444ff99071f4c01d005bcefe0d3567e1be628e5fdcd9"); + Ok::<_, Error>(()) + })?; + Ok(()) + } + + #[test] + fn test_crypto_digest_internal() -> Result<()> { + let mut config = Config::default(); + config.crypto(true); + let runtime = Runtime::new(config)?; + + runtime.context().with(|this| { + let result = this.eval::, _>( + r#"globalThis.__javy_cryptoSubtleSign({name: "HMAC", hash: "sha-256"}, "my secret and secure key", "input message");"#, + ); + assert_eq!(val_to_string(&this, result.unwrap()).unwrap(), "97d2a569059bbcd8ead4444ff99071f4c01d005bcefe0d3567e1be628e5fdcd9"); + Ok::<_, Error>(()) + })?; + Ok(()) + } + + #[test] + fn test_crypto_disabled_by_default() -> Result<()> { + let runtime = Runtime::new(Config::default())?; + + runtime.context().with(|this| { + let result = this.eval::, _>( + r#" + crypto.subtle; + "#, + ); + assert!(result.is_err()); + let e = result + .map_err(|e| from_js_error(this.clone(), e)) + .unwrap_err(); + assert_eq!( + "Error:2:21 'crypto' is not defined\n at (eval_script:2:21)\n", + e.to_string() + ); + Ok::<_, Error>(()) + })?; + Ok(()) + } + + #[test] + fn test_crypto_digest_with_lossy_input() -> Result<()> { + let mut config = Config::default(); + config.crypto(true); + let runtime = Runtime::new(config)?; + + runtime.context().with(|this| { + let value = this.eval::, _>( + r#"crypto.subtle.sign({name: "HMAC", hash: "sha-256"}, "\uD800\uD800\uD800\uD800\uD800", "\uD800\uD800\uD800\uD800\uD800");"#, + ); + let result = extract_promise_to_string(this.clone(), value.unwrap().clone()); + assert_eq!(result?, "c06ae855290abd8f397af6975e9c2f72fe27a90a3e0f0bb73b4f991567501980"); + Ok::<_, Error>(()) + })?; + Ok(()) + } + + #[test] + fn test_crypto_undefined_methods_raise_not_a_function() -> Result<()> { + let mut config = Config::default(); + config.crypto(true); + let runtime = Runtime::new(config)?; + + runtime.context().with(|this| { + let result= this.eval::, _>( + r#" + crypto.subtle.encrypt({name: "HMAC", hash: "sha-256"}, "my secret and secure key", "input message"); + "#, + ); + assert!(result.is_err()); + let e = result.map_err(|e| from_js_error(this.clone(), e)).unwrap_err(); + assert_eq!("Error:2:35 not a function\n at (eval_script:2:35)\n", e.to_string()); + Ok::<_, Error>(()) + })?; + Ok(()) + } + + #[test] + fn test_not_hmac_algo_errors() -> Result<()> { + let mut config = Config::default(); + config.crypto(true); + let runtime = Runtime::new(config)?; + + runtime.context().with(|this| { + let value = this.eval::, _>( + r#"crypto.subtle.sign({name: "not-HMAC", hash: "not-sha-256"}, "my secret and secure key", "input message");"#, + ); + let e = extract_promise_to_err(this.clone(), value.unwrap().clone())?; + assert_eq!("Error:11:15 only name=HMAC supported\n at (eval_script:11:15)\n at Promise (native)\n at (eval_script:12:12)\n at (eval_script:1:15)\n", e.to_string()); + Ok::<_, Error>(()) + })?; + Ok(()) + } + + #[test] + fn test_not_sha256_algo_errors() -> Result<()> { + let mut config = Config::default(); + config.crypto(true); + let runtime = Runtime::new(config)?; + + runtime.context().with(|this| { + let value = this.eval::, _>( + r#"crypto.subtle.sign({name: "HMAC", hash: "not-sha-256"}, "my secret and secure key", "input message");"#, + ); + let e = extract_promise_to_err(this.clone(), value.unwrap().clone())?; + assert_eq!("Error:11:15 only hash=sha-256 supported\n at (eval_script:11:15)\n at Promise (native)\n at (eval_script:12:12)\n at (eval_script:1:15)\n", e.to_string()); + Ok::<_, Error>(()) + })?; + Ok(()) + } +} diff --git a/crates/javy/src/apis/mod.rs b/crates/javy/src/apis/mod.rs index 67979d7e..9beb495d 100644 --- a/crates/javy/src/apis/mod.rs +++ b/crates/javy/src/apis/mod.rs @@ -57,6 +57,8 @@ //! //! Disabled by default. pub(crate) mod console; +#[cfg(feature = "crypto")] +pub(crate) mod crypto; #[cfg(feature = "json")] pub(crate) mod json; pub(crate) mod random; @@ -64,6 +66,8 @@ pub(crate) mod stream_io; pub(crate) mod text_encoding; pub(crate) use console::*; +#[cfg(feature = "crypto")] +pub(crate) use crypto::*; #[cfg(feature = "json")] pub(crate) use json::*; pub(crate) use random::*; diff --git a/crates/javy/src/config.rs b/crates/javy/src/config.rs index 6f542642..f211c96f 100644 --- a/crates/javy/src/config.rs +++ b/crates/javy/src/config.rs @@ -19,6 +19,7 @@ bitflags! { const OPERATORS = 1 << 12; const BIGNUM_EXTENSION = 1 << 13; const TEXT_ENCODING = 1 << 14; + const CRYPTO = 1 << 15; } } @@ -66,6 +67,7 @@ impl Default for Config { fn default() -> Self { let mut intrinsics = JSIntrinsics::all(); intrinsics.set(JSIntrinsics::TEXT_ENCODING, false); + intrinsics.set(JSIntrinsics::CRYPTO, false); Self { intrinsics, javy_intrinsics: JavyIntrinsics::empty(), @@ -179,6 +181,14 @@ impl Config { self } + /// Whether the `crypto` intrinsic will be available. + /// Disabled by default. + #[cfg(feature = "crypto")] + pub fn crypto(&mut self, enable: bool) -> &mut Self { + self.intrinsics.set(JSIntrinsics::CRYPTO, enable); + self + } + /// Enables whether the output of console.log will be redirected to /// `stderr`. pub fn redirect_stdout_to_stderr(&mut self, enable: bool) -> &mut Self { diff --git a/crates/javy/src/runtime.rs b/crates/javy/src/runtime.rs index 22d2f9ab..1947b3d0 100644 --- a/crates/javy/src/runtime.rs +++ b/crates/javy/src/runtime.rs @@ -144,6 +144,13 @@ impl Runtime { JavyJson::add_intrinsic(ctx.as_raw()) } } + + if intrinsics.contains(JSIntrinsics::CRYPTO) { + #[cfg(feature = "crypto")] + unsafe { + crate::apis::Crypto::add_intrinsic(ctx.as_raw()) + } + } }); Ok(ManuallyDrop::new(context)) diff --git a/npm/javy/CHANGELOG.md b/npm/javy/CHANGELOG.md index 7a1f3e67..33214934 100644 --- a/npm/javy/CHANGELOG.md +++ b/npm/javy/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Added crypto HmacSha256 API support. + ## [0.1.2] - 2023-07-28 ### Added diff --git a/p.js b/p.js new file mode 100644 index 00000000..2d092012 --- /dev/null +++ b/p.js @@ -0,0 +1,11 @@ + +export async function main() { + const expectedHex = "97d2a569059bbcd8ead4444ff99071f4c01d005bcefe0d3567e1be628e5fdcd9"; + + const result = await crypto.subtle.sign({name: "HMAC", hash: "sha-256"}, "my secret and secure key", "input message"); + console.log(result); + console.log(result === expectedHex); +} + +await main(); + diff --git a/wpt/custom_tests/hmac.any.js b/wpt/custom_tests/hmac.any.js new file mode 100644 index 00000000..d72b15bc --- /dev/null +++ b/wpt/custom_tests/hmac.any.js @@ -0,0 +1,37 @@ +// META: script=/WebCryptoAPI/sign-verify/hmac.js + +function getTestVectors() { + var plaintext = new Uint8Array([95, 77, 186, 79, 50, 12, 12, 232, 118, 114, 90, 252, 229, 251, 210, 91, 248, 62, 90, 113, 37, 160, 140, 175, 231, 60, 62, 186, 196, 33, 119, 157, 249, 213, 93, 24, 12, 58, 233, 148, 38, 69, 225, 216, 47, 238, 140, 157, 41, 75, 60, 177, 160, 138, 153, 49, 32, 27, 60, 14, 129, 252, 71, 202, 207, 131, 21, 162, 175, 102, 50, 65, 19, 195, 182, 98, 48, 195, 70, 8, 196, 244, 89, 54, 52, 206, 2, 178, 103, 54, 34, 119, 240, 168, 64, 202, 116, 188, 61, 26, 98, 54, 149, 44, 94, 215, 170, 248, 168, 254, 203, 221, 250, 117, 132, 230, 151, 140, 234, 93, 42, 91, 159, 183, 241, 180, 140, 139, 11, 229, 138, 48, 82, 2, 117, 77, 131, 118, 16, 115, 116, 121, 60, 240, 38, 170, 238, 83, 0, 114, 125, 131, 108, 215, 30, 113, 179, 69, 221, 178, 228, 68, 70, 255, 197, 185, 1, 99, 84, 19, 137, 13, 145, 14, 163, 128, 152, 74, 144, 25, 16, 49, 50, 63, 22, 219, 204, 157, 107, 225, 104, 184, 72, 133, 56, 76, 160, 62, 18, 96, 10, 193, 194, 72, 2, 138, 243, 114, 108, 201, 52, 99, 136, 46, 168, 192, 42, 171]); + + var raw = { + "SHA-256": new Uint8Array([229, 136, 236, 8, 17, 70, 61, 118, 114, 65, 223, 16, 116, 180, 122, 228, 7, 27, 81, 242, 206, 54, 83, 123, 166, 156, 205, 195, 253, 194, 183, 168]), + }; + + var signatures = { + "SHA-256": new Uint8Array([133, 164, 12, 234, 46, 7, 140, 40, 39, 163, 149, 63, 251, 102, 194, 123, 41, 26, 71, 43, 13, 112, 160, 0, 11, 69, 216, 35, 128, 62, 235, 84]), + }; + + // Each test vector has the following fields: + // name - a unique name for this vector + // keyBuffer - an arrayBuffer with the key data + // key - a CryptoKey object for the keyBuffer. INITIALLY null! You must fill this in first to use it! + // hashName - the hash function to sign with + // plaintext - the text to encrypt + // signature - the expected signature + var vectors = []; + Object.keys(raw).forEach(function(hashName) { + vectors.push({ + name: "HMAC with " + hashName, + hash: hashName, + keyBuffer: raw[hashName], + key: null, + plaintext: plaintext, + signature: signatures[hashName] + }); + }); + + return vectors; +} + + +run_test(); diff --git a/wpt/test_spec.js b/wpt/test_spec.js index 7470033a..6641fc77 100644 --- a/wpt/test_spec.js +++ b/wpt/test_spec.js @@ -67,4 +67,7 @@ export default [ { testFile: "upstream/encoding/textencoder-utf16-surrogates.any.js", }, + // { + // testFile: "upstream/WebCryptoAPI/sign_verify/hmac.https.any.js", + // }, ];