diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index aa1d1f9..dda317f 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -52,6 +52,7 @@ features = [ "Win32_System_Kernel", "Win32_System_EventLog", "Win32_Security", + "Win32_Security_Cryptography", "Win32_System_Ole", "Win32_System_Threading", "Win32_System_WinRT", diff --git a/runtime/src/global_fns.rs b/runtime/src/global_fns.rs index 2f44535..002132a 100644 --- a/runtime/src/global_fns.rs +++ b/runtime/src/global_fns.rs @@ -4403,6 +4403,7 @@ pub(crate) fn init_async_helpers( register!("__tns_uptime", handle_tns_uptime); register!("__nsUUID", handle_ns_uuid); register!("__nsRandomFill", handle_ns_random_fill); + register!("__nsSubtle", crate::subtle_v8::subtle); register!("__nsIsUiThread", handle_is_ui_thread); register!("__nsThreadInfo", handle_thread_info); register!("__nsGetLastJsError", handle_get_last_js_error); diff --git a/runtime/src/interop_test.rs b/runtime/src/interop_test.rs index a7fb6e4..0100516 100644 --- a/runtime/src/interop_test.rs +++ b/runtime/src/interop_test.rs @@ -2854,6 +2854,60 @@ fn crypto_random_values_and_uuid() { ); } +#[test] +fn crypto_subtle() { + run_js_assert( + "crypto_subtle", + r#" + const eq = (actual, expected, what) => { + const a = JSON.stringify(actual), e = JSON.stringify(expected); + if (a !== e) throw new Error(`${what}: expected ${e}, got ${a}`); + }; + const hex = (b) => Array.from(new Uint8Array(b), (x) => x.toString(16).padStart(2, '0')).join(''); + const bytes = (h) => new Uint8Array(h.match(/../g).map((x) => parseInt(x, 16))); + const utf8 = (s) => new TextEncoder().encode(s); + const rejects = (p) => p.then(() => 'resolved', (e) => e.name); + return (async () => { + const s = crypto.subtle; + eq(hex(await s.digest('SHA-256', utf8('abc'))), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', 'digest'); + eq(await rejects(s.digest('MD5', utf8('abc'))), 'NotSupportedError', 'unknown digest'); + eq(await rejects(s.digest('SHA-256', 'abc')), 'TypeError', 'digest of a string'); + + const hmac = { name: 'HMAC', hash: 'SHA-256' }; + const jefe = await s.importKey('raw', utf8('Jefe'), hmac, true, ['sign', 'verify']); + eq([jefe instanceof CryptoKey, jefe.type, jefe.extractable, jefe.usages, jefe.algorithm.length], [true, 'secret', true, ['sign', 'verify'], 32], 'CryptoKey'); + const tag = await s.sign('HMAC', jefe, utf8('what do ya want for nothing?')); + eq(hex(tag), '5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843', 'HMAC RFC 4231'); + eq(await s.verify('HMAC', jefe, tag, utf8('what do ya want for nothing?')), true, 'verify'); + eq(await s.verify('HMAC', jefe, tag, utf8('tampered')), false, 'verify tampered'); + const jwk = await s.exportKey('jwk', jefe); + eq([jwk.kty, jwk.alg, jwk.k], ['oct', 'HS256', 'SmVmZQ'], 'jwk export'); + eq(await rejects(s.generateKey(hmac, false, ['encrypt'])), 'SyntaxError', 'bad usage'); + + const aes = await s.importKey('raw', bytes('feffe9928665731c6d6a8f9467308308'), 'AES-GCM', false, ['encrypt', 'decrypt']); + const gcm = { name: 'AES-GCM', iv: bytes('cafebabefacedbaddecaf888'), additionalData: bytes('feedfacedeadbeeffeedfacedeadbeefabaddad2') }; + const plain = bytes('d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39'); + const sealed = await s.encrypt(gcm, aes, plain); + eq(hex(sealed).slice(-32), '5bc94fbc3221a5db94fae95ae7121a47', 'AES-GCM tag'); + eq(hex(await s.decrypt(gcm, aes, sealed)), hex(plain), 'AES-GCM round trip'); + const broken = new Uint8Array(sealed); + broken[0] ^= 1; + eq(await rejects(s.decrypt(gcm, aes, broken)), 'OperationError', 'AES-GCM tampered'); + + const password = await s.importKey('raw', utf8('password'), 'PBKDF2', false, ['deriveBits', 'deriveKey']); + const pbkdf2 = { name: 'PBKDF2', hash: 'SHA-1', salt: utf8('salt'), iterations: 4096 }; + eq(hex(await s.deriveBits(pbkdf2, password, 160)), '4b007901b765489abead49d926f721d065a429c1', 'PBKDF2 RFC 6070'); + const derived = await s.deriveKey(pbkdf2, password, { name: 'AES-GCM', length: 256 }, true, ['encrypt']); + eq([derived.algorithm.name, (await s.exportKey('raw', derived)).byteLength], ['AES-GCM', 32], 'deriveKey'); + + const ikm = await s.importKey('raw', bytes('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b'), 'HKDF', false, ['deriveBits']); + const hkdf = { name: 'HKDF', hash: 'SHA-256', salt: bytes('000102030405060708090a0b0c'), info: bytes('f0f1f2f3f4f5f6f7f8f9') }; + eq(hex(await s.deriveBits(hkdf, ikm, 336)), '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865', 'HKDF RFC 5869'); + })(); + "#, + ); +} + #[test] fn delegate_arguments_must_be_delegates() { run_js_assert( diff --git a/runtime/src/js/crypto.js b/runtime/src/js/crypto.js index 37a049c..0948808 100644 --- a/runtime/src/js/crypto.js +++ b/runtime/src/js/crypto.js @@ -1,10 +1,29 @@ -// Web Crypto's random source, filled from the OS RNG by `__nsRandomFill`. Hosts that already have +// Web Crypto: the OS RNG via `__nsRandomFill`, `subtle` via `__nsSubtle`. Hosts that already have // `crypto` (Node, Deno, Bun) keep their own. (function () { var fill = globalThis.__nsRandomFill; - if (typeof fill !== 'function') return; + var subtleNative = globalThis.__nsSubtle; + if (typeof fill !== 'function' && typeof subtleNative !== 'function') return; var crypto = globalThis.crypto || (globalThis.crypto = {}); + if (typeof subtleNative === 'function' && !crypto.subtle) { + var CryptoKey = function CryptoKey() { + throw new TypeError('Illegal constructor'); + }; + var subtle = {}; + ['digest', 'generateKey', 'importKey', 'exportKey', 'sign', 'verify', 'encrypt', 'decrypt', + 'deriveBits', 'deriveKey', 'wrapKey', 'unwrapKey'].forEach(function (op) { + subtle[op] = function () { + var args = Array.prototype.slice.call(arguments); + return new Promise(function (resolve) { + resolve(subtleNative(op, args, CryptoKey.prototype)); + }); + }; + }); + crypto.subtle = subtle; + globalThis.CryptoKey = CryptoKey; + } + function isIntegerArray(array) { return array instanceof Int8Array || array instanceof Uint8Array || array instanceof Uint8ClampedArray || array instanceof Int16Array || @@ -14,7 +33,7 @@ (array instanceof BigInt64Array || array instanceof BigUint64Array)); } - if (typeof crypto.getRandomValues !== 'function') { + if (typeof fill === 'function' && typeof crypto.getRandomValues !== 'function') { crypto.getRandomValues = function getRandomValues(array) { var prefix = "Failed to execute 'getRandomValues' on 'Crypto': "; if (!isIntegerArray(array)) { diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 0a3507e..9b9f78a 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -47,6 +47,9 @@ mod property_call; #[cfg(feature = "classic")] mod proxy_manifest_loader; pub mod source_protect; +mod subtle; +#[cfg(feature = "classic")] +mod subtle_v8; mod struct_reference; #[cfg(feature = "classic")] mod struct_value; diff --git a/runtime/src/napi_engine/interop.rs b/runtime/src/napi_engine/interop.rs index 2cca9ce..ef6275d 100644 --- a/runtime/src/napi_engine/interop.rs +++ b/runtime/src/napi_engine/interop.rs @@ -345,6 +345,9 @@ pub fn install_interop(env: &Env) -> napi::Result<()> { })?; global.set_named_property("__nsRandomFill", random_fn)?; + let subtle_fn = env.create_function_from_closure("__nsSubtle", crate::napi_engine::subtle::call)?; + global.set_named_property("__nsSubtle", subtle_fn)?; + let func_ctor: JsFunction = global.get_named_property("Function")?; let sources = [ INTEROP_HELPERS_JS, diff --git a/runtime/src/napi_engine/mod.rs b/runtime/src/napi_engine/mod.rs index 330d488..a2e1415 100644 --- a/runtime/src/napi_engine/mod.rs +++ b/runtime/src/napi_engine/mod.rs @@ -22,6 +22,7 @@ pub mod module_natives; pub mod module_runner; pub mod ns_hostobject; pub mod ns_proxy; +pub mod subtle; pub mod timers; pub mod url; pub mod value; diff --git a/runtime/src/napi_engine/subtle.rs b/runtime/src/napi_engine/subtle.rs new file mode 100644 index 0000000..d829138 --- /dev/null +++ b/runtime/src/napi_engine/subtle.rs @@ -0,0 +1,192 @@ +//! `crypto.subtle` for napi engines: `__nsSubtle(op, args, CryptoKey.prototype)`. + +use crate::napi_engine::value::buffer_data; +use crate::subtle::{dispatch, Js as Engine, Key}; +use napi::{ + sys, CallContext, Env, JsFunction, JsObject, JsUnknown, NapiRaw, NapiValue, Property, PropertyAttributes, + Status, ValueType, +}; +use serde_json::Value; + +struct Napi<'e> { + env: &'e Env, + proto: sys::napi_value, +} + +impl Napi<'_> { + fn value(&self, raw: sys::napi_value) -> JsUnknown { + unsafe { JsUnknown::from_raw_unchecked(self.env.raw(), raw) } + } + + fn object(&self, raw: sys::napi_value) -> Option { + matches!(self.value(raw).get_type(), Ok(ValueType::Object | ValueType::Function)) + .then(|| unsafe { JsObject::from_raw_unchecked(self.env.raw(), raw) }) + } + + fn call_global(&self, object: &str, method: &str, arg: sys::napi_value) -> Option { + let target: JsObject = self.env.get_global().ok()?.get_named_property(object).ok()?; + let f: JsFunction = target.get_named_property(method).ok()?; + let out = f.call(Some(&target), &[self.value(arg)]); + if out.is_err() { + let mut exception = std::ptr::null_mut(); + unsafe { sys::napi_get_and_clear_last_exception(self.env.raw(), &mut exception) }; + } + out.ok() + } +} + +impl Engine for Napi<'_> { + type V = sys::napi_value; + + fn undefined(&mut self) -> Self::V { + self.env.get_undefined().map(|u| unsafe { u.raw() }).unwrap_or(std::ptr::null_mut()) + } + + fn is_nullish(&mut self, v: Self::V) -> bool { + matches!(self.value(v).get_type(), Ok(ValueType::Undefined | ValueType::Null)) + } + + fn string(&mut self, v: Self::V) -> Option { + let value = self.value(v); + if value.get_type().ok()? != ValueType::String { + return None; + } + Some(value.coerce_to_string().ok()?.into_utf8().ok()?.as_str().ok()?.to_owned()) + } + + fn number(&mut self, v: Self::V) -> Option { + let value = self.value(v); + if value.get_type().ok()? != ValueType::Number { + return None; + } + value.coerce_to_number().ok()?.get_double().ok() + } + + fn boolean(&mut self, v: Self::V) -> bool { + self.value(v).coerce_to_bool().and_then(|b| b.get_value()).unwrap_or(false) + } + + fn bytes(&mut self, v: Self::V) -> Option> { + let (data, len) = buffer_data(self.env, &self.value(v))?; + Some(if len == 0 || data.is_null() { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(data as *const u8, len).to_vec() } + }) + } + + fn get(&mut self, v: Self::V, name: &str) -> Self::V { + self.object(v) + .and_then(|o| o.get_named_property::(name).ok()) + .map(|value| unsafe { value.raw() }) + .unwrap_or_else(|| self.undefined()) + } + + fn array(&mut self, v: Self::V) -> Option> { + let array = self.object(v)?; + if !array.is_array().ok()? { + return None; + } + (0..array.get_array_length().ok()?) + .map(|i| array.get_element::(i).ok().map(|e| unsafe { e.raw() })) + .collect() + } + + fn stringify(&mut self, v: Self::V) -> Option { + self.object(v)?; + let text = self.call_global("JSON", "stringify", v)?; + self.string(unsafe { text.raw() }) + } + + fn key(&mut self, v: Self::V) -> Option { + let object = self.object(v)?; + self.env.unwrap::(&object).ok().map(|key| key.clone()) + } + + fn array_buffer(&mut self, bytes: Vec) -> Self::V { + self.env + .create_arraybuffer_with_data(bytes) + .map(|b| unsafe { b.into_raw().raw() }) + .unwrap_or_else(|_| self.undefined()) + } + + fn bool_value(&mut self, b: bool) -> Self::V { + self.env.get_boolean(b).map(|b| unsafe { b.raw() }).unwrap_or_else(|_| self.undefined()) + } + + fn json(&mut self, value: &Value) -> Self::V { + let Ok(text) = self.env.create_string(&value.to_string()) else { + return self.undefined(); + }; + self.call_global("JSON", "parse", unsafe { text.raw() }) + .map(|v| unsafe { v.raw() }) + .unwrap_or_else(|| self.undefined()) + } + + fn crypto_key(&mut self, key: Key) -> Self::V { + let algorithm = self.json(&key.algorithm.to_json()); + let created = self.call_global("Object", "create", self.proto); + let mut object = match created.and_then(|o| self.object(unsafe { o.raw() })) { + Some(o) => o, + None => match self.env.create_object() { + Ok(o) => o, + Err(_) => return self.undefined(), + }, + }; + let built = (|| -> napi::Result<()> { + let mut usages = self.env.create_array_with_length(key.usages.len())?; + for (i, usage) in key.usages.iter().enumerate() { + usages.set_element(i as u32, self.env.create_string(usage)?)?; + } + let kind = self.env.create_string(key.kind())?; + let extractable = self.env.get_boolean(key.extractable)?; + let algorithm = self.value(algorithm); + object.define_properties(&[ + Property::new("type")?.with_value(&kind).with_property_attributes(PropertyAttributes::Enumerable), + Property::new("extractable")? + .with_value(&extractable) + .with_property_attributes(PropertyAttributes::Enumerable), + Property::new("algorithm")? + .with_value(&algorithm) + .with_property_attributes(PropertyAttributes::Enumerable), + Property::new("usages")?.with_value(&usages).with_property_attributes(PropertyAttributes::Enumerable), + ])?; + self.env.wrap(&mut object, key) + })(); + if built.is_err() { + return self.undefined(); + } + unsafe { object.raw() } + } +} + +fn throw(env: &Env, name: &str, message: &str) -> napi::Error { + let thrown = (|| -> napi::Result<()> { + let global = env.get_global()?; + let text = env.create_string(message)?; + let error = if name == "TypeError" { + let ctor: JsFunction = global.get_named_property("TypeError")?; + ctor.new_instance(&[text])? + } else { + let ctor: JsFunction = global.get_named_property("DOMException")?; + ctor.new_instance(&[text.into_unknown(), env.create_string(name)?.into_unknown()])? + }; + env.throw(error) + })(); + match thrown { + Ok(()) => napi::Error::new(Status::PendingException, String::new()), + Err(_) => napi::Error::from_reason(format!("{name}: {message}")), + } +} + +pub(crate) fn call(ctx: CallContext) -> napi::Result { + let op = ctx.get::(0)?.coerce_to_string()?.into_utf8()?.as_str()?.to_owned(); + let args = unsafe { ctx.get::(1)?.raw() }; + let proto = unsafe { ctx.get::(2)?.raw() }; + let mut engine = Napi { env: ctx.env, proto }; + let args = engine.array(args).unwrap_or_default(); + match dispatch(&mut engine, &op, &args) { + Ok(v) => Ok(unsafe { JsUnknown::from_raw_unchecked(ctx.env.raw(), v) }), + Err(e) => Err(throw(ctx.env, e.name, &e.message)), + } +} diff --git a/runtime/src/subtle.rs b/runtime/src/subtle.rs new file mode 100644 index 0000000..3e79e69 --- /dev/null +++ b/runtime/src/subtle.rs @@ -0,0 +1,661 @@ +//! `crypto.subtle`: digest, HMAC, AES-GCM, PBKDF2, HKDF. Primitives are in `backend`. + +mod backend; + +use serde_json::{json, Map, Value}; + +#[derive(Debug, PartialEq)] +pub struct Error { + pub name: &'static str, + pub message: String, +} + +type Result = std::result::Result; + +fn err(name: &'static str, message: impl Into) -> Result { + Err(Error { name, message: message.into() }) +} + +fn operation_failed() -> Error { + Error { name: "OperationError", message: "The operation failed for an operation-specific reason".into() } +} + +fn not_supported(what: &str) -> Result { + err("NotSupportedError", format!("{what} is not supported")) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Hash { + Sha1, + Sha256, + Sha384, + Sha512, +} + +impl Hash { + fn parse(name: &str) -> Result { + Ok(match name.to_ascii_uppercase().as_str() { + "SHA-1" => Hash::Sha1, + "SHA-256" => Hash::Sha256, + "SHA-384" => Hash::Sha384, + "SHA-512" => Hash::Sha512, + _ => return not_supported(&format!("Hash '{name}'")), + }) + } + + fn name(self) -> &'static str { + match self { + Hash::Sha1 => "SHA-1", + Hash::Sha256 => "SHA-256", + Hash::Sha384 => "SHA-384", + Hash::Sha512 => "SHA-512", + } + } + + fn block_bits(self) -> u32 { + match self { + Hash::Sha1 | Hash::Sha256 => 512, + Hash::Sha384 | Hash::Sha512 => 1024, + } + } + + fn jwk_hmac(self) -> &'static str { + match self { + Hash::Sha1 => "HS1", + Hash::Sha256 => "HS256", + Hash::Sha384 => "HS384", + Hash::Sha512 => "HS512", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Algorithm { + Hmac { hash: Hash, length: u32 }, + AesGcm { length: u32 }, + Pbkdf2, + Hkdf, +} + +impl Algorithm { + fn name(&self) -> &'static str { + match self { + Algorithm::Hmac { .. } => "HMAC", + Algorithm::AesGcm { .. } => "AES-GCM", + Algorithm::Pbkdf2 => "PBKDF2", + Algorithm::Hkdf => "HKDF", + } + } + + pub fn to_json(&self) -> Value { + match self { + Algorithm::Hmac { hash, length } => { + json!({ "name": "HMAC", "hash": { "name": hash.name() }, "length": length }) + } + Algorithm::AesGcm { length } => json!({ "name": "AES-GCM", "length": length }), + _ => json!({ "name": self.name() }), + } + } + + fn allowed_usages(&self) -> &'static [&'static str] { + match self { + Algorithm::Hmac { .. } => &["sign", "verify"], + Algorithm::AesGcm { .. } => &["encrypt", "decrypt", "wrapKey", "unwrapKey"], + Algorithm::Pbkdf2 | Algorithm::Hkdf => &["deriveBits", "deriveKey"], + } + } +} + +#[derive(Clone, Debug)] +pub struct Key { + pub algorithm: Algorithm, + pub extractable: bool, + pub usages: Vec, + secret: Vec, +} + +impl Key { + pub fn kind(&self) -> &'static str { + "secret" + } + + /// algorithm JSON, NUL, secret + pub fn encode(&self) -> Vec { + let header = json!({ "algorithm": self.algorithm.to_json(), "extractable": self.extractable, "usages": self.usages }); + let mut out = header.to_string().into_bytes(); + out.push(0); + out.extend_from_slice(&self.secret); + out + } + + pub fn decode(bytes: &[u8]) -> Option { + let split = bytes.iter().position(|b| *b == 0)?; + let header: Value = serde_json::from_slice(&bytes[..split]).ok()?; + let alg = &header["algorithm"]; + let algorithm = match alg["name"].as_str()? { + "HMAC" => Algorithm::Hmac { + hash: Hash::parse(alg["hash"]["name"].as_str()?).ok()?, + length: alg["length"].as_u64()? as u32, + }, + "AES-GCM" => Algorithm::AesGcm { length: alg["length"].as_u64()? as u32 }, + "PBKDF2" => Algorithm::Pbkdf2, + "HKDF" => Algorithm::Hkdf, + _ => return None, + }; + Some(Key { + algorithm, + extractable: header["extractable"].as_bool()?, + usages: serde_json::from_value(header["usages"].clone()).ok()?, + secret: bytes[split + 1..].to_vec(), + }) + } + + fn require(&self, algorithm: &str, usage: &str) -> Result<()> { + if self.algorithm.name() != algorithm { + return err("InvalidAccessError", format!("The key is not a {algorithm} key")); + } + if !self.usages.iter().any(|u| u == usage) { + return err("InvalidAccessError", format!("The key does not support '{usage}'")); + } + Ok(()) + } +} + +pub trait Js { + type V: Copy; + fn undefined(&mut self) -> Self::V; + fn is_nullish(&mut self, v: Self::V) -> bool; + fn string(&mut self, v: Self::V) -> Option; + fn number(&mut self, v: Self::V) -> Option; + fn boolean(&mut self, v: Self::V) -> bool; + fn bytes(&mut self, v: Self::V) -> Option>; + /// undefined for non-objects + fn get(&mut self, v: Self::V, name: &str) -> Self::V; + fn array(&mut self, v: Self::V) -> Option>; + fn stringify(&mut self, v: Self::V) -> Option; + fn key(&mut self, v: Self::V) -> Option; + + fn array_buffer(&mut self, bytes: Vec) -> Self::V; + fn bool_value(&mut self, b: bool) -> Self::V; + fn json(&mut self, value: &Value) -> Self::V; + fn crypto_key(&mut self, key: Key) -> Self::V; +} + +fn type_error(message: impl Into) -> Result { + err("TypeError", message) +} + +fn arg(args: &[J::V], js: &mut J, i: usize) -> J::V { + args.get(i).copied().unwrap_or_else(|| js.undefined()) +} + +fn data(js: &mut J, v: J::V, what: &str) -> Result> { + js.bytes(v).map_or_else(|| type_error(format!("{what} is not a BufferSource")), Ok) +} + +fn algorithm_name(js: &mut J, v: J::V) -> Result { + if let Some(name) = js.string(v) { + return Ok(name); + } + let name = js.get(v, "name"); + js.string(name).map_or_else(|| type_error("Algorithm: name is missing"), Ok) +} + +fn canonical(name: &str) -> String { + name.to_ascii_uppercase() +} + +fn member_hash(js: &mut J, alg: J::V) -> Result { + let hash = js.get(alg, "hash"); + if js.is_nullish(hash) { + return type_error("Algorithm: hash is missing"); + } + Hash::parse(&algorithm_name(js, hash)?) +} + +fn member_bytes(js: &mut J, alg: J::V, name: &str) -> Result>> { + let v = js.get(alg, name); + if js.is_nullish(v) { + return Ok(None); + } + data(js, v, &format!("Algorithm: {name}")).map(Some) +} + +fn member_u32(js: &mut J, alg: J::V, name: &str) -> Result> { + let v = js.get(alg, name); + if js.is_nullish(v) { + return Ok(None); + } + match js.number(v) { + Some(n) if n.is_finite() && n >= 0.0 && n <= u32::MAX as f64 => Ok(Some(n as u32)), + _ => type_error(format!("Algorithm: {name} is not a valid unsigned long")), + } +} + +fn usages(js: &mut J, v: J::V, allowed: &[&str]) -> Result> { + let Some(items) = js.array(v) else { + return type_error("keyUsages is not a sequence"); + }; + let mut out: Vec = Vec::new(); + for item in items { + let usage = js.string(item).unwrap_or_default(); + if !["encrypt", "decrypt", "sign", "verify", "deriveKey", "deriveBits", "wrapKey", "unwrapKey"] + .contains(&usage.as_str()) + { + return type_error(format!("'{usage}' is not a valid KeyUsage")); + } + if !allowed.contains(&usage.as_str()) { + return err("SyntaxError", format!("Usage '{usage}' is not allowed for this key")); + } + if !out.contains(&usage) { + out.push(usage); + } + } + Ok(out) +} + +fn random(len: usize) -> Result> { + let mut out = vec![0; len]; + backend::random(&mut out) + .map_err(|_| Error { name: "OperationError", message: "the random source failed".into() })?; + Ok(out) +} + +fn aes_length(bits: u32) -> Result { + match bits { + 128 | 256 => Ok(bits), + 192 => not_supported("AES-192"), + _ => err("OperationError", format!("AES key length must be 128 or 256 bits, got {bits}")), + } +} + +fn hmac_length(js: &mut J, alg: J::V) -> Result> { + match member_u32(js, alg, "length")? { + Some(0) => type_error("HMAC: length must not be zero"), + length => Ok(length), + } +} + +fn key_algorithm(js: &mut J, alg: J::V, op: &str) -> Result<(Algorithm, Option)> { + let name = canonical(&algorithm_name(js, alg)?); + Ok(match name.as_str() { + "HMAC" => { + let hash = member_hash(js, alg)?; + let length = hmac_length(js, alg)?; + (Algorithm::Hmac { hash, length: length.unwrap_or(hash.block_bits()) }, length) + } + "AES-GCM" => { + let length = member_u32(js, alg, "length")?; + let length = match (length, op) { + (Some(n), _) => Some(aes_length(n)?), + (None, "importKey") => None, + (None, _) => return type_error("AES-GCM: length is missing"), + }; + (Algorithm::AesGcm { length: length.unwrap_or(0) }, length) + } + "PBKDF2" if op == "importKey" => (Algorithm::Pbkdf2, None), + "HKDF" if op == "importKey" => (Algorithm::Hkdf, None), + _ => return not_supported(&format!("{op} with '{name}'")), + }) +} + +fn generate_key(js: &mut J, args: &[J::V]) -> Result { + let alg = arg(args, js, 0); + let (algorithm, _) = key_algorithm(js, alg, "generateKey")?; + let extractable = { + let v = arg(args, js, 1); + js.boolean(v) + }; + let usages_v = arg(args, js, 2); + let usages = usages(js, usages_v, algorithm.allowed_usages())?; + if usages.is_empty() { + return err("SyntaxError", "keyUsages must not be empty"); + } + let bits = match &algorithm { + Algorithm::Hmac { length, .. } | Algorithm::AesGcm { length } => *length, + _ => unreachable!(), + }; + let secret = random(bits.div_ceil(8) as usize)?; + Ok(js.crypto_key(Key { algorithm, extractable, usages, secret })) +} + +const BASE64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +fn base64url(bytes: &[u8]) -> String { + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i)); + for i in 0..=chunk.len() { + out.push(BASE64URL[(n >> (18 - 6 * i) & 63) as usize] as char); + } + } + out +} + +fn from_base64url(text: &str) -> Option> { + let mut out = Vec::new(); + let (mut acc, mut bits) = (0u32, 0); + for c in text.trim_end_matches('=').bytes() { + acc = acc << 6 | BASE64URL.iter().position(|b| *b == c)? as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +fn import_key(js: &mut J, args: &[J::V]) -> Result { + let format_v = arg(args, js, 0); + let format = js.string(format_v).unwrap_or_default(); + let key_data = arg(args, js, 1); + let alg = arg(args, js, 2); + let (mut algorithm, length) = key_algorithm(js, alg, "importKey")?; + let extractable = { + let v = arg(args, js, 3); + js.boolean(v) + }; + let usages_v = arg(args, js, 4); + let usages = usages(js, usages_v, algorithm.allowed_usages())?; + + let secret = match format.as_str() { + "raw" => data(js, key_data, "keyData")?, + "jwk" => { + if matches!(algorithm, Algorithm::Pbkdf2 | Algorithm::Hkdf) { + return not_supported(&format!("jwk import of {}", algorithm.name())); + } + let text = js.stringify(key_data).unwrap_or_default(); + let jwk: Value = serde_json::from_str(&text).map_err(|_| Error { + name: "DataError", + message: "keyData is not a JsonWebKey".into(), + })?; + jwk_secret(&jwk, &algorithm, &usages, extractable)? + } + "spki" | "pkcs8" => return not_supported(&format!("'{format}' for secret keys")), + _ => return type_error(format!("'{format}' is not a valid KeyFormat")), + }; + if matches!(algorithm, Algorithm::Pbkdf2 | Algorithm::Hkdf) && extractable { + return err("SyntaxError", format!("{} keys can't be extractable", algorithm.name())); + } + let bits = secret.len() as u32 * 8; + match &mut algorithm { + Algorithm::Hmac { length: l, .. } => { + if bits == 0 { + return err("DataError", "HMAC key data must not be empty"); + } + match length { + Some(n) if n > bits || n <= bits - 8 => { + return err("DataError", "HMAC length doesn't match the key data") + } + Some(n) => *l = n, + None => *l = bits, + } + } + Algorithm::AesGcm { length: l } => { + aes_length(bits).map_err(|_| Error { + name: "DataError", + message: format!("AES key data must be 16 or 32 bytes, got {}", secret.len()), + })?; + if length.is_some_and(|n| n != bits) { + return err("DataError", "AES-GCM length doesn't match the key data"); + } + *l = bits; + } + _ => {} + } + if usages.is_empty() { + return err("SyntaxError", "keyUsages must not be empty"); + } + Ok(js.crypto_key(Key { algorithm, extractable, usages, secret })) +} + +fn jwk_secret(jwk: &Value, algorithm: &Algorithm, usages: &[String], extractable: bool) -> Result> { + let data_error = |m: &str| Error { name: "DataError", message: m.into() }; + if jwk["kty"] != "oct" { + return Err(data_error("JWK kty must be 'oct'")); + } + let k = jwk["k"].as_str().ok_or_else(|| data_error("JWK k is missing"))?; + let secret = from_base64url(k).ok_or_else(|| data_error("JWK k is not base64url"))?; + let expected = match algorithm { + Algorithm::Hmac { hash, .. } => hash.jwk_hmac().to_string(), + Algorithm::AesGcm { .. } => format!("A{}GCM", secret.len() * 8), + _ => unreachable!(), + }; + if let Some(alg) = jwk.get("alg").and_then(Value::as_str) { + if alg != expected { + return Err(data_error(&format!("JWK alg '{alg}' doesn't match {expected}"))); + } + } + if let Some(use_) = jwk.get("use").and_then(Value::as_str) { + let expected = if matches!(algorithm, Algorithm::Hmac { .. }) { "sig" } else { "enc" }; + if !usages.is_empty() && use_ != expected { + return Err(data_error(&format!("JWK use must be '{expected}'"))); + } + } + if let Some(ops) = jwk.get("key_ops").and_then(Value::as_array) { + if usages.iter().any(|u| !ops.iter().any(|o| o == u.as_str())) { + return Err(data_error("keyUsages are not a subset of the JWK key_ops")); + } + } + if extractable && jwk.get("ext") == Some(&Value::Bool(false)) { + return Err(data_error("The JWK is not extractable")); + } + Ok(secret) +} + +fn key_arg(js: &mut J, args: &[J::V], i: usize) -> Result { + let v = arg(args, js, i); + js.key(v).map_or_else(|| type_error("key is not a CryptoKey"), Ok) +} + +fn export_key(js: &mut J, args: &[J::V]) -> Result { + let format_v = arg(args, js, 0); + let format = js.string(format_v).unwrap_or_default(); + let key = key_arg(js, args, 1)?; + if !key.extractable { + return err("InvalidAccessError", "The key is not extractable"); + } + if matches!(key.algorithm, Algorithm::Pbkdf2 | Algorithm::Hkdf) { + return not_supported(&format!("Exporting {} keys", key.algorithm.name())); + } + match format.as_str() { + "raw" => Ok(js.array_buffer(key.secret)), + "jwk" => { + let alg = match key.algorithm { + Algorithm::Hmac { hash, .. } => hash.jwk_hmac().to_string(), + Algorithm::AesGcm { length } => format!("A{length}GCM"), + _ => unreachable!(), + }; + let mut jwk = Map::new(); + jwk.insert("kty".into(), "oct".into()); + jwk.insert("k".into(), base64url(&key.secret).into()); + jwk.insert("alg".into(), alg.into()); + jwk.insert("key_ops".into(), key.usages.clone().into()); + jwk.insert("ext".into(), true.into()); + Ok(js.json(&Value::Object(jwk))) + } + "spki" | "pkcs8" => not_supported(&format!("'{format}' for secret keys")), + _ => type_error(format!("'{format}' is not a valid KeyFormat")), + } +} + +fn hmac_hash(key: &Key) -> Hash { + let Algorithm::Hmac { hash, .. } = key.algorithm else { unreachable!() }; + hash +} + +fn sign(js: &mut J, args: &[J::V]) -> Result { + let alg = arg(args, js, 0); + let name = canonical(&algorithm_name(js, alg)?); + if name != "HMAC" { + return not_supported(&format!("sign with '{name}'")); + } + let key = key_arg(js, args, 1)?; + key.require("HMAC", "sign")?; + let d = arg(args, js, 2); + let message = data(js, d, "data")?; + let tag = backend::hmac_sign(hmac_hash(&key), &key.secret, &message).map_err(|_| operation_failed())?; + Ok(js.array_buffer(tag)) +} + +fn verify(js: &mut J, args: &[J::V]) -> Result { + let alg = arg(args, js, 0); + let name = canonical(&algorithm_name(js, alg)?); + if name != "HMAC" { + return not_supported(&format!("verify with '{name}'")); + } + let key = key_arg(js, args, 1)?; + key.require("HMAC", "verify")?; + let s = arg(args, js, 2); + let signature = data(js, s, "signature")?; + let d = arg(args, js, 3); + let message = data(js, d, "data")?; + let ok = backend::hmac_verify(hmac_hash(&key), &key.secret, &message, &signature); + Ok(js.bool_value(ok)) +} + +fn aes_gcm(js: &mut J, args: &[J::V], encrypt: bool) -> Result { + let op = if encrypt { "encrypt" } else { "decrypt" }; + let alg = arg(args, js, 0); + let name = canonical(&algorithm_name(js, alg)?); + if name != "AES-GCM" { + return not_supported(&format!("{op} with '{name}'")); + } + let key = key_arg(js, args, 1)?; + key.require("AES-GCM", op)?; + let Some(iv) = member_bytes(js, alg, "iv")? else { + return type_error("AES-GCM: iv is missing"); + }; + let aad = member_bytes(js, alg, "additionalData")?.unwrap_or_default(); + match member_u32(js, alg, "tagLength")? { + None | Some(128) => {} + Some(32 | 64 | 96 | 104 | 112 | 120) => return not_supported("An AES-GCM tagLength other than 128"), + Some(n) => return err("OperationError", format!("{n} is not a valid AES-GCM tagLength")), + } + if iv.len() != 12 { + return not_supported("An AES-GCM iv that isn't 12 bytes"); + } + let d = arg(args, js, 2); + let input = data(js, d, "data")?; + let output = if encrypt { + backend::aes_gcm_seal(&key.secret, &iv, &aad, &input) + } else { + backend::aes_gcm_open(&key.secret, &iv, &aad, &input) + }; + let buffer = output.map_err(|_| operation_failed())?; + Ok(js.array_buffer(buffer)) +} + +fn derive(js: &mut J, alg: J::V, key: &Key, usage: &str, bits: Option) -> Result> { + let name = canonical(&algorithm_name(js, alg)?); + let Some(bits) = bits else { + return err("OperationError", "length is required"); + }; + if bits % 8 != 0 { + return err("OperationError", "length must be a multiple of 8"); + } + if bits == 0 { + return err("OperationError", "length must not be zero"); + } + let mut out = vec![0; bits as usize / 8]; + match name.as_str() { + "PBKDF2" => { + key.require("PBKDF2", usage)?; + let hash = member_hash(js, alg)?; + let Some(salt) = member_bytes(js, alg, "salt")? else { + return type_error("PBKDF2: salt is missing"); + }; + let Some(iterations) = member_u32(js, alg, "iterations")? else { + return type_error("PBKDF2: iterations is missing"); + }; + if iterations == 0 { + return err("OperationError", "PBKDF2: iterations must not be zero"); + } + backend::pbkdf2(hash, iterations, &salt, &key.secret, &mut out) + .map_err(|_| Error { name: "OperationError", message: "PBKDF2 failed".into() })?; + } + "HKDF" => { + key.require("HKDF", usage)?; + let hash = member_hash(js, alg)?; + let Some(salt) = member_bytes(js, alg, "salt")? else { + return type_error("HKDF: salt is missing"); + }; + let Some(info) = member_bytes(js, alg, "info")? else { + return type_error("HKDF: info is missing"); + }; + backend::hkdf(hash, &salt, &key.secret, &info, &mut out) + .map_err(|_| Error { name: "OperationError", message: "HKDF: length is too large".into() })?; + } + _ => return not_supported(&format!("deriving with '{name}'")), + } + Ok(out) +} + +fn derive_bits(js: &mut J, args: &[J::V]) -> Result { + let alg = arg(args, js, 0); + let key = key_arg(js, args, 1)?; + let length_v = arg(args, js, 2); + let bits = if js.is_nullish(length_v) { + None + } else { + js.number(length_v).filter(|n| n.is_finite() && *n >= 0.0).map(|n| n as u32) + }; + let out = derive(js, alg, &key, "deriveBits", bits)?; + Ok(js.array_buffer(out)) +} + +fn derive_key(js: &mut J, args: &[J::V]) -> Result { + let alg = arg(args, js, 0); + let key = key_arg(js, args, 1)?; + let derived = arg(args, js, 2); + let (mut algorithm, length) = key_algorithm(js, derived, "deriveKey")?; + let extractable = { + let v = arg(args, js, 3); + js.boolean(v) + }; + let usages_v = arg(args, js, 4); + let usages = usages(js, usages_v, algorithm.allowed_usages())?; + if usages.is_empty() { + return err("SyntaxError", "keyUsages must not be empty"); + } + let bits = match &mut algorithm { + Algorithm::Hmac { hash, length: l } => { + *l = length.unwrap_or(hash.block_bits()); + *l + } + Algorithm::AesGcm { length } => *length, + _ => unreachable!(), + }; + let secret = derive(js, alg, &key, "deriveKey", Some(bits.div_ceil(8) * 8))?; + Ok(js.crypto_key(Key { algorithm, extractable, usages, secret })) +} + +fn digest_op(js: &mut J, args: &[J::V]) -> Result { + let alg = arg(args, js, 0); + let hash = Hash::parse(&algorithm_name(js, alg)?)?; + let d = arg(args, js, 1); + let message = data(js, d, "data")?; + let out = backend::digest(hash, &message).map_err(|_| operation_failed())?; + Ok(js.array_buffer(out)) +} + +pub fn dispatch(js: &mut J, op: &str, args: &[J::V]) -> Result { + match op { + "digest" => digest_op(js, args), + "generateKey" => generate_key(js, args), + "importKey" => import_key(js, args), + "exportKey" => export_key(js, args), + "sign" => sign(js, args), + "verify" => verify(js, args), + "encrypt" => aes_gcm(js, args, true), + "decrypt" => aes_gcm(js, args, false), + "deriveBits" => derive_bits(js, args), + "deriveKey" => derive_key(js, args), + _ => not_supported(&format!("crypto.subtle.{op}")), + } +} + +#[cfg(test)] +mod tests; diff --git a/runtime/src/subtle/backend.rs b/runtime/src/subtle/backend.rs new file mode 100644 index 0000000..9d8896d --- /dev/null +++ b/runtime/src/subtle/backend.rs @@ -0,0 +1,154 @@ +//! CNG (bcrypt) primitives. + +use super::Hash; +use windows::core::PCWSTR; +use windows::Win32::Foundation::NTSTATUS; +use windows::Win32::Security::Cryptography::*; + +fn ok(status: NTSTATUS) -> Result<(), ()> { + status.is_ok().then_some(()).ok_or(()) +} + +fn hash_len(hash: Hash) -> usize { + match hash { + Hash::Sha1 => 20, + Hash::Sha256 => 32, + Hash::Sha384 => 48, + Hash::Sha512 => 64, + } +} + +pub fn random(out: &mut [u8]) -> Result<(), ()> { + ok(unsafe { BCryptGenRandom(None, out, BCRYPT_USE_SYSTEM_PREFERRED_RNG) }) +} + +pub fn digest(hash: Hash, data: &[u8]) -> Result, ()> { + let algorithm = match hash { + Hash::Sha1 => BCRYPT_SHA1_ALG_HANDLE, + Hash::Sha256 => BCRYPT_SHA256_ALG_HANDLE, + Hash::Sha384 => BCRYPT_SHA384_ALG_HANDLE, + Hash::Sha512 => BCRYPT_SHA512_ALG_HANDLE, + }; + let mut out = vec![0; hash_len(hash)]; + ok(unsafe { BCryptHash(algorithm, None, data, &mut out) })?; + Ok(out) +} + +pub fn hmac_sign(hash: Hash, secret: &[u8], data: &[u8]) -> Result, ()> { + let algorithm = match hash { + Hash::Sha1 => BCRYPT_HMAC_SHA1_ALG_HANDLE, + Hash::Sha256 => BCRYPT_HMAC_SHA256_ALG_HANDLE, + Hash::Sha384 => BCRYPT_HMAC_SHA384_ALG_HANDLE, + Hash::Sha512 => BCRYPT_HMAC_SHA512_ALG_HANDLE, + }; + let mut out = vec![0; hash_len(hash)]; + ok(unsafe { BCryptHash(algorithm, Some(secret), data, &mut out) })?; + Ok(out) +} + +pub fn hmac_verify(hash: Hash, secret: &[u8], data: &[u8], signature: &[u8]) -> bool { + let Ok(expected) = hmac_sign(hash, secret, data) else { + return false; + }; + expected.len() == signature.len() + && expected.iter().zip(signature).fold(0, |acc, (a, b)| acc | (a ^ b)) == 0 +} + +struct AesKey(BCRYPT_KEY_HANDLE); + +impl AesKey { + fn new(secret: &[u8]) -> Result { + let mut key = BCRYPT_KEY_HANDLE::default(); + ok(unsafe { BCryptGenerateSymmetricKey(BCRYPT_AES_GCM_ALG_HANDLE, &mut key, None, secret, 0) })?; + Ok(AesKey(key)) + } +} + +impl Drop for AesKey { + fn drop(&mut self) { + unsafe { + let _ = BCryptDestroyKey(self.0); + } + } +} + +fn mode_info(iv: &mut [u8], aad: &mut [u8], tag: &mut [u8]) -> BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO { + BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO { + cbSize: std::mem::size_of::() as u32, + dwInfoVersion: BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO_VERSION, + pbNonce: iv.as_mut_ptr(), + cbNonce: iv.len() as u32, + pbAuthData: if aad.is_empty() { std::ptr::null_mut() } else { aad.as_mut_ptr() }, + cbAuthData: aad.len() as u32, + pbTag: tag.as_mut_ptr(), + cbTag: tag.len() as u32, + ..Default::default() + } +} + +/// ciphertext followed by the 16-byte tag +pub fn aes_gcm_seal(secret: &[u8], iv: &[u8], aad: &[u8], data: &[u8]) -> Result, ()> { + let key = AesKey::new(secret)?; + let (mut iv, mut aad, mut tag) = (iv.to_vec(), aad.to_vec(), [0u8; 16]); + let info = mode_info(&mut iv, &mut aad, &mut tag); + let mut out = vec![0; data.len()]; + let mut written = 0u32; + ok(unsafe { + BCryptEncrypt(key.0, Some(data), Some(&info as *const _ as *const _), None, Some(&mut out), &mut written, BCRYPT_FLAGS(0)) + })?; + out.truncate(written as usize); + out.extend_from_slice(&tag); + Ok(out) +} + +pub fn aes_gcm_open(secret: &[u8], iv: &[u8], aad: &[u8], data: &[u8]) -> Result, ()> { + let Some(split) = data.len().checked_sub(16) else { + return Err(()); + }; + let key = AesKey::new(secret)?; + let (mut iv, mut aad) = (iv.to_vec(), aad.to_vec()); + let mut tag = data[split..].to_vec(); + let info = mode_info(&mut iv, &mut aad, &mut tag); + let mut out = vec![0; split]; + let mut written = 0u32; + ok(unsafe { + BCryptDecrypt(key.0, Some(&data[..split]), Some(&info as *const _ as *const _), None, Some(&mut out), &mut written, BCRYPT_FLAGS(0)) + })?; + out.truncate(written as usize); + Ok(out) +} + +pub fn pbkdf2(hash: Hash, iterations: u32, salt: &[u8], secret: &[u8], out: &mut [u8]) -> Result<(), ()> { + let id = match hash { + Hash::Sha1 => BCRYPT_SHA1_ALGORITHM, + Hash::Sha256 => BCRYPT_SHA256_ALGORITHM, + Hash::Sha384 => BCRYPT_SHA384_ALGORITHM, + Hash::Sha512 => BCRYPT_SHA512_ALGORITHM, + }; + let mut prf = BCRYPT_ALG_HANDLE::default(); + ok(unsafe { BCryptOpenAlgorithmProvider(&mut prf, id, PCWSTR::null(), BCRYPT_ALG_HANDLE_HMAC_FLAG) })?; + let status = unsafe { BCryptDeriveKeyPBKDF2(prf, Some(secret), Some(salt), iterations as u64, out, 0) }; + unsafe { + let _ = BCryptCloseAlgorithmProvider(prf, 0); + } + ok(status) +} + +/// RFC 5869 over CNG HMAC +pub fn hkdf(hash: Hash, salt: &[u8], ikm: &[u8], info: &[u8], out: &mut [u8]) -> Result<(), ()> { + let len = hash_len(hash); + if out.len() > 255 * len { + return Err(()); + } + let zeros = vec![0; len]; + let prk = hmac_sign(hash, if salt.is_empty() { &zeros } else { salt }, ikm)?; + let mut t = Vec::new(); + for (i, chunk) in out.chunks_mut(len).enumerate() { + let mut input = std::mem::take(&mut t); + input.extend_from_slice(info); + input.push(i as u8 + 1); + t = hmac_sign(hash, &prk, &input)?; + chunk.copy_from_slice(&t[..chunk.len()]); + } + Ok(()) +} diff --git a/runtime/src/subtle/tests.rs b/runtime/src/subtle/tests.rs new file mode 100644 index 0000000..014cad3 --- /dev/null +++ b/runtime/src/subtle/tests.rs @@ -0,0 +1,367 @@ +use super::*; + +#[derive(Clone)] +enum M { + Undefined, + Str(String), + Num(f64), + Bool(bool), + Bytes(Vec), + Obj(Vec<(String, usize)>), + Arr(Vec), + Key(Key), + Json(Value), +} + +#[derive(Default)] +struct Mock(Vec); + +impl Mock { + fn add(&mut self, v: M) -> usize { + self.0.push(v); + self.0.len() - 1 + } + fn s(&mut self, v: &str) -> usize { + self.add(M::Str(v.into())) + } + fn n(&mut self, v: f64) -> usize { + self.add(M::Num(v)) + } + fn b(&mut self, v: &[u8]) -> usize { + self.add(M::Bytes(v.to_vec())) + } + fn t(&mut self, v: bool) -> usize { + self.add(M::Bool(v)) + } + fn obj(&mut self, members: &[(&str, usize)]) -> usize { + self.add(M::Obj(members.iter().map(|(k, v)| (k.to_string(), *v)).collect())) + } + fn usages(&mut self, list: &[&str]) -> usize { + let items = list.iter().map(|u| self.s(u)).collect(); + self.add(M::Arr(items)) + } + fn call(&mut self, op: &str, args: &[usize]) -> Result { + dispatch(self, op, args) + } + fn bytes_of(&self, v: usize) -> Vec { + match &self.0[v] { + M::Bytes(b) => b.clone(), + _ => panic!("not bytes"), + } + } + fn key_of(&self, v: usize) -> Key { + match &self.0[v] { + M::Key(k) => k.clone(), + _ => panic!("not a key"), + } + } +} + +impl Js for Mock { + type V = usize; + fn undefined(&mut self) -> usize { + self.add(M::Undefined) + } + fn is_nullish(&mut self, v: usize) -> bool { + matches!(self.0[v], M::Undefined) + } + fn string(&mut self, v: usize) -> Option { + match &self.0[v] { + M::Str(s) => Some(s.clone()), + _ => None, + } + } + fn number(&mut self, v: usize) -> Option { + match self.0[v] { + M::Num(n) => Some(n), + _ => None, + } + } + fn boolean(&mut self, v: usize) -> bool { + matches!(self.0[v], M::Bool(true)) + } + fn bytes(&mut self, v: usize) -> Option> { + match &self.0[v] { + M::Bytes(b) => Some(b.clone()), + _ => None, + } + } + fn get(&mut self, v: usize, name: &str) -> usize { + let found = match &self.0[v] { + M::Obj(members) => members.iter().find(|(k, _)| k == name).map(|(_, v)| *v), + _ => None, + }; + found.unwrap_or_else(|| self.undefined()) + } + fn array(&mut self, v: usize) -> Option> { + match &self.0[v] { + M::Arr(items) => Some(items.clone()), + _ => None, + } + } + fn stringify(&mut self, v: usize) -> Option { + match &self.0[v] { + M::Json(j) => Some(j.to_string()), + _ => None, + } + } + fn key(&mut self, v: usize) -> Option { + match &self.0[v] { + M::Key(k) => Some(k.clone()), + _ => None, + } + } + fn array_buffer(&mut self, bytes: Vec) -> usize { + self.add(M::Bytes(bytes)) + } + fn bool_value(&mut self, b: bool) -> usize { + self.add(M::Bool(b)) + } + fn json(&mut self, value: &Value) -> usize { + self.add(M::Json(value.clone())) + } + fn crypto_key(&mut self, key: Key) -> usize { + self.add(M::Key(key)) + } +} + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +fn to_hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +#[test] +fn digest_sha_family() { + let mut js = Mock::default(); + let data = js.b(b"abc"); + for (name, expected) in [ + ("SHA-1", "a9993e364706816aba3e25717850c26c9cd0d89d"), + ("sha-256", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), + ("SHA-384", "cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7"), + ("SHA-512", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"), + ] { + let alg = js.s(name); + let out = js.call("digest", &[alg, data]).unwrap(); + assert_eq!(to_hex(&js.bytes_of(out)), expected, "{name}"); + } + let dict = { + let n = js.s("SHA-256"); + js.obj(&[("name", n)]) + }; + assert!(js.call("digest", &[dict, data]).is_ok()); + let bad = js.s("MD5"); + assert_eq!(js.call("digest", &[bad, data]).unwrap_err().name, "NotSupportedError"); +} + +fn hmac_alg(js: &mut Mock, hash: &str) -> usize { + let name = js.s("HMAC"); + let hash = js.s(hash); + js.obj(&[("name", name), ("hash", hash)]) +} + +#[test] +fn hmac_rfc4231_case_2() { + let mut js = Mock::default(); + let format = js.s("raw"); + let secret = js.b(b"Jefe"); + let alg = hmac_alg(&mut js, "SHA-256"); + let extractable = js.t(true); + let usages = js.usages(&["sign", "verify"]); + let key = js.call("importKey", &[format, secret, alg, extractable, usages]).unwrap(); + assert_eq!(js.key_of(key).algorithm, Algorithm::Hmac { hash: Hash::Sha256, length: 32 }); + let data = js.b(b"what do ya want for nothing?"); + let sig = js.call("sign", &[alg, key, data]).unwrap(); + assert_eq!( + to_hex(&js.bytes_of(sig)), + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" + ); + let ok = js.call("verify", &[alg, key, sig, data]).unwrap(); + assert!(matches!(js.0[ok], M::Bool(true))); + let other = js.b(b"tampered"); + let bad = js.call("verify", &[alg, key, sig, other]).unwrap(); + assert!(matches!(js.0[bad], M::Bool(false))); +} + +#[test] +fn hmac_generate_and_jwk_round_trip() { + let mut js = Mock::default(); + let alg = hmac_alg(&mut js, "SHA-512"); + let extractable = js.t(true); + let usages = js.usages(&["sign"]); + let key = js.call("generateKey", &[alg, extractable, usages]).unwrap(); + assert_eq!(js.key_of(key).secret.len(), 128); + let jwk_format = js.s("jwk"); + let jwk = js.call("exportKey", &[jwk_format, key]).unwrap(); + let M::Json(value) = js.0[jwk].clone() else { panic!() }; + assert_eq!(value["alg"], "HS512"); + let imported = js.call("importKey", &[jwk_format, jwk, alg, extractable, usages]).unwrap(); + assert_eq!(js.key_of(imported).secret, js.key_of(key).secret); +} + +#[test] +fn usages_are_checked() { + let mut js = Mock::default(); + let alg = hmac_alg(&mut js, "SHA-256"); + let extractable = js.t(false); + let usages = js.usages(&["encrypt"]); + assert_eq!(js.call("generateKey", &[alg, extractable, usages]).unwrap_err().name, "SyntaxError"); + let none = js.usages(&[]); + assert_eq!(js.call("generateKey", &[alg, extractable, none]).unwrap_err().name, "SyntaxError"); + let sign_only = js.usages(&["sign"]); + let key = js.call("generateKey", &[alg, extractable, sign_only]).unwrap(); + let data = js.b(b"x"); + assert_eq!(js.call("verify", &[alg, key, data, data]).unwrap_err().name, "InvalidAccessError"); + let raw = js.s("raw"); + assert_eq!(js.call("exportKey", &[raw, key]).unwrap_err().name, "InvalidAccessError"); +} + +fn aes_alg(js: &mut Mock, iv: &[u8], aad: Option<&[u8]>) -> usize { + let name = js.s("AES-GCM"); + let iv = js.b(iv); + match aad { + Some(a) => { + let a = js.b(a); + js.obj(&[("name", name), ("iv", iv), ("additionalData", a)]) + } + None => js.obj(&[("name", name), ("iv", iv)]), + } +} + +#[test] +fn aes_gcm_nist_vector() { + // GCM spec test case 4 + let mut js = Mock::default(); + let raw = js.s("raw"); + let secret = js.b(&hex("feffe9928665731c6d6a8f9467308308")); + let name = js.s("AES-GCM"); + let import_alg = js.obj(&[("name", name)]); + let extractable = js.t(false); + let usages = js.usages(&["encrypt", "decrypt"]); + let key = js.call("importKey", &[raw, secret, import_alg, extractable, usages]).unwrap(); + let alg = aes_alg( + &mut js, + &hex("cafebabefacedbaddecaf888"), + Some(&hex("feedfacedeadbeeffeedfacedeadbeefabaddad2")), + ); + let plain = hex("d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39"); + let data = js.b(&plain); + let sealed = js.call("encrypt", &[alg, key, data]).unwrap(); + assert_eq!( + to_hex(&js.bytes_of(sealed)), + "42831ec2217774244b7221b784d0d49ce3aa212f2c02a4e035c17e2329aca12e21d514b25466931c7d8f6a5aac84aa051ba30b396a0aac973d58e091\ +5bc94fbc3221a5db94fae95ae7121a47" + ); + let opened = js.call("decrypt", &[alg, key, sealed]).unwrap(); + assert_eq!(js.bytes_of(opened), plain); + let mut tampered = js.bytes_of(sealed); + tampered[0] ^= 1; + let tampered = js.b(&tampered); + assert_eq!(js.call("decrypt", &[alg, key, tampered]).unwrap_err().name, "OperationError"); +} + +#[test] +fn aes_gcm_limits() { + let mut js = Mock::default(); + let name = js.s("AES-GCM"); + let l192 = js.n(192.0); + let alg = js.obj(&[("name", name), ("length", l192)]); + let extractable = js.t(true); + let usages = js.usages(&["encrypt"]); + assert_eq!(js.call("generateKey", &[alg, extractable, usages]).unwrap_err().name, "NotSupportedError"); + let l100 = js.n(100.0); + let alg = js.obj(&[("name", name), ("length", l100)]); + assert_eq!(js.call("generateKey", &[alg, extractable, usages]).unwrap_err().name, "OperationError"); + let l256 = js.n(256.0); + let alg = js.obj(&[("name", name), ("length", l256)]); + let key = js.call("generateKey", &[alg, extractable, usages]).unwrap(); + let short_iv = aes_alg(&mut js, &[0; 8], None); + let data = js.b(b"x"); + assert_eq!(js.call("encrypt", &[short_iv, key, data]).unwrap_err().name, "NotSupportedError"); + let iv = aes_alg(&mut js, &[0; 12], None); + assert_eq!(js.call("decrypt", &[iv, key, data]).unwrap_err().name, "InvalidAccessError"); +} + +fn base_key(js: &mut Mock, name: &str, secret: &[u8]) -> usize { + let raw = js.s("raw"); + let secret = js.b(secret); + let alg = js.s(name); + let extractable = js.t(false); + let usages = js.usages(&["deriveBits", "deriveKey"]); + js.call("importKey", &[raw, secret, alg, extractable, usages]).unwrap() +} + +#[test] +fn pbkdf2_rfc6070() { + let mut js = Mock::default(); + let key = base_key(&mut js, "PBKDF2", b"password"); + let name = js.s("PBKDF2"); + let hash = js.s("SHA-1"); + let salt = js.b(b"salt"); + let iterations = js.n(4096.0); + let alg = js.obj(&[("name", name), ("hash", hash), ("salt", salt), ("iterations", iterations)]); + let length = js.n(160.0); + let out = js.call("deriveBits", &[alg, key, length]).unwrap(); + assert_eq!(to_hex(&js.bytes_of(out)), "4b007901b765489abead49d926f721d065a429c1"); +} + +#[test] +fn hkdf_rfc5869_case_1() { + let mut js = Mock::default(); + let key = base_key(&mut js, "HKDF", &hex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")); + let name = js.s("HKDF"); + let hash = js.s("SHA-256"); + let salt = js.b(&hex("000102030405060708090a0b0c")); + let info = js.b(&hex("f0f1f2f3f4f5f6f7f8f9")); + let alg = js.obj(&[("name", name), ("hash", hash), ("salt", salt), ("info", info)]); + let length = js.n(42.0 * 8.0); + let out = js.call("deriveBits", &[alg, key, length]).unwrap(); + assert_eq!( + to_hex(&js.bytes_of(out)), + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + ); +} + +#[test] +fn derive_key_makes_an_aes_key() { + let mut js = Mock::default(); + let key = base_key(&mut js, "PBKDF2", b"password"); + let name = js.s("PBKDF2"); + let hash = js.s("SHA-256"); + let salt = js.b(b"salt"); + let iterations = js.n(1000.0); + let alg = js.obj(&[("name", name), ("hash", hash), ("salt", salt), ("iterations", iterations)]); + let aes = js.s("AES-GCM"); + let length = js.n(256.0); + let derived_type = js.obj(&[("name", aes), ("length", length)]); + let extractable = js.t(true); + let usages = js.usages(&["encrypt", "decrypt"]); + let derived = js.call("deriveKey", &[alg, key, derived_type, extractable, usages]).unwrap(); + let derived = js.key_of(derived); + assert_eq!(derived.algorithm, Algorithm::AesGcm { length: 256 }); + assert_eq!(derived.secret.len(), 32); +} + +#[test] +fn base64url_round_trip() { + for len in 0..8 { + let bytes: Vec = (0..len).map(|i| (i * 37 + 250) as u8).collect(); + assert_eq!(from_base64url(&base64url(&bytes)), Some(bytes)); + } + assert_eq!(base64url(b"Jefe"), "SmVmZQ"); + assert_eq!(base64url(&[0xfb, 0xff]), "-_8"); +} + +#[test] +fn unsupported_operations() { + let mut js = Mock::default(); + let rsa = js.s("RSA-OAEP"); + assert_eq!(js.call("encrypt", &[rsa]).unwrap_err().name, "NotSupportedError"); + assert_eq!(js.call("wrapKey", &[]).unwrap_err().name, "NotSupportedError"); + let ecdsa = js.s("ECDSA"); + let extractable = js.t(true); + let usages = js.usages(&["sign"]); + assert_eq!(js.call("generateKey", &[ecdsa, extractable, usages]).unwrap_err().name, "NotSupportedError"); +} diff --git a/runtime/src/subtle_v8.rs b/runtime/src/subtle_v8.rs new file mode 100644 index 0000000..5b61ebf --- /dev/null +++ b/runtime/src/subtle_v8.rs @@ -0,0 +1,167 @@ +use crate::subtle::{dispatch, Js, Key}; +use serde_json::Value; + +type Args<'s> = v8::FunctionCallbackArguments<'s>; +type Rv<'s> = v8::ReturnValue<'s, v8::Value>; + +fn key_slot<'s>(scope: &v8::PinScope<'s, '_, ()>) -> v8::Local<'s, v8::Private> { + let name = v8::String::new(scope, "ns#cryptoKey").unwrap(); + v8::Private::for_api(scope, Some(name)) +} + +struct V8<'a, 's, 'i> { + scope: &'a mut v8::PinScope<'s, 'i>, + proto: v8::Local<'s, v8::Value>, +} + +impl<'a, 's, 'i> V8<'a, 's, 'i> { + fn str(&mut self, s: &str) -> v8::Local<'s, v8::String> { + v8::String::new(self.scope, s).unwrap() + } +} + +impl<'a, 's, 'i> Js for V8<'a, 's, 'i> { + type V = v8::Local<'s, v8::Value>; + + fn undefined(&mut self) -> Self::V { + v8::undefined(self.scope).into() + } + + fn is_nullish(&mut self, v: Self::V) -> bool { + v.is_null_or_undefined() + } + + fn string(&mut self, v: Self::V) -> Option { + v.is_string().then(|| v.to_rust_string_lossy(self.scope)) + } + + fn number(&mut self, v: Self::V) -> Option { + if v.is_number() { + v.number_value(self.scope) + } else { + None + } + } + + fn boolean(&mut self, v: Self::V) -> bool { + v.boolean_value(self.scope) + } + + fn bytes(&mut self, v: Self::V) -> Option> { + if let Ok(view) = v8::Local::::try_from(v) { + let mut out = vec![0; view.byte_length()]; + view.copy_contents(&mut out); + return Some(out); + } + let buffer = v8::Local::::try_from(v).ok()?; + Some(match buffer.data() { + Some(p) => unsafe { + std::slice::from_raw_parts(p.as_ptr() as *const u8, buffer.byte_length()).to_vec() + }, + None => Vec::new(), + }) + } + + fn get(&mut self, v: Self::V, name: &str) -> Self::V { + let Ok(object) = v8::Local::::try_from(v) else { + return self.undefined(); + }; + let key = self.str(name); + object + .get(self.scope, key.into()) + .unwrap_or_else(|| v8::undefined(self.scope).into()) + } + + fn array(&mut self, v: Self::V) -> Option> { + let array = v8::Local::::try_from(v).ok()?; + (0..array.length()) + .map(|i| array.get_index(self.scope, i)) + .collect() + } + + fn stringify(&mut self, v: Self::V) -> Option { + v.is_object() + .then(|| v8::json::stringify(self.scope, v)) + .flatten() + .map(|s| s.to_rust_string_lossy(self.scope)) + } + + fn key(&mut self, v: Self::V) -> Option { + let object = v8::Local::::try_from(v).ok()?; + let slot = key_slot(self.scope); + let stored = object.get_private(self.scope, slot)?; + let bytes = self.bytes(stored)?; + Key::decode(&bytes) + } + + fn array_buffer(&mut self, bytes: Vec) -> Self::V { + let store = v8::ArrayBuffer::new_backing_store_from_vec(bytes).make_shared(); + v8::ArrayBuffer::with_backing_store(self.scope, &store).into() + } + + fn bool_value(&mut self, b: bool) -> Self::V { + v8::Boolean::new(self.scope, b).into() + } + + fn json(&mut self, value: &Value) -> Self::V { + let text = self.str(&value.to_string()); + v8::json::parse(self.scope, text).unwrap_or_else(|| v8::undefined(self.scope).into()) + } + + fn crypto_key(&mut self, key: Key) -> Self::V { + let object = v8::Object::new(self.scope); + object.set_prototype(self.scope, self.proto); + let usages: Vec> = + key.usages.iter().map(|u| self.str(u).into()).collect(); + let usages = v8::Array::new_with_elements(self.scope, &usages); + let algorithm = self.json(&key.algorithm.to_json()); + let kind = self.str(key.kind()).into(); + let extractable = self.bool_value(key.extractable); + for (name, value) in [ + ("type", kind), + ("extractable", extractable), + ("algorithm", algorithm), + ("usages", usages.into()), + ] { + let name = self.str(name); + object.define_own_property(self.scope, name.into(), value, v8::PropertyAttribute::READ_ONLY); + } + let stored = self.array_buffer(key.encode()); + let slot = key_slot(self.scope); + object.set_private(self.scope, slot, stored); + object.into() + } +} + +fn throw<'s>(scope: &mut v8::PinScope<'s, '_>, name: &str, message: &str) { + let text = v8::String::new(scope, message).unwrap(); + let exception = if name == "TypeError" { + v8::Exception::type_error(scope, text) + } else { + let context = scope.get_current_context(); + let key = v8::String::new(scope, "DOMException").unwrap(); + let name = v8::String::new(scope, name).unwrap(); + context + .global(scope) + .get(scope, key.into()) + .and_then(|c| v8::Local::::try_from(c).ok()) + .and_then(|c| c.new_instance(scope, &[text.into(), name.into()])) + .map(Into::into) + .unwrap_or_else(|| v8::Exception::error(scope, text)) + }; + scope.throw_exception(exception); +} + +/// subtle(op, args, CryptoKey.prototype) +pub fn subtle<'s>(scope: &mut v8::PinScope<'s, '_>, args: Args<'s>, mut rv: Rv<'s>) { + let op = args.get(0).to_rust_string_lossy(scope); + let values: Vec> = match v8::Local::::try_from(args.get(1)) { + Ok(a) => (0..a.length()).filter_map(|i| a.get_index(scope, i)).collect(), + Err(_) => Vec::new(), + }; + let result = dispatch(&mut V8 { scope, proto: args.get(2) }, &op, &values); + match result { + Ok(v) => rv.set(v), + Err(e) => throw(scope, e.name, &e.message), + } +}