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.
This commit is contained in:
MrBeanDev committed 2026-07-01 12:11:55 +05:30
1 parent d5d9b7788f
commit ed75730e2d
4 files changed
+705 -27

No files matched your search

+241
View File
@@ -1383,6 +1383,240 @@ fn op_subtle_digest(#[string] algorithm: &str, #[buffer] data: &[u8]) -> Vec<u8>
}
}
// ---------------------------------------------------------------------------
// 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<Vec<u8>, 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<Vec<u8>, deno_error::JsErrorBox> {
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::aes::{Aes192, Aes256};
use aes_gcm::{AesGcm, Nonce};
type Aes192Gcm = AesGcm<Aes192, aes_gcm::aead::consts::U12>;
type Aes256Gcm = AesGcm<Aes256, aes_gcm::aead::consts::U12>;
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<Vec<u8>, 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::<Pkcs7>(data)
} else {
Decryptor::<$cipher>::new_from_slices(key, iv)
.map_err(crypto_err)?
.decrypt_padded_vec_mut::<Pkcs7>(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<Vec<u8>, 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<aes::Aes128>),
24 => run!(ctr::$flavor<aes::Aes192>),
32 => run!(ctr::$flavor<aes::Aes256>),
_ => 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<Vec<u8>, deno_error::JsErrorBox> {
use pbkdf2::pbkdf2_hmac;
let mut dk = vec![0u8; length as usize];
match hash {
"SHA-1" => pbkdf2_hmac::<sha1::Sha1>(password, salt, iterations, &mut dk),
"SHA-256" => pbkdf2_hmac::<sha2::Sha256>(password, salt, iterations, &mut dk),
"SHA-384" => pbkdf2_hmac::<sha2::Sha384>(password, salt, iterations, &mut dk),
"SHA-512" => pbkdf2_hmac::<sha2::Sha512>(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<Vec<u8>, 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<Vec<u8>, 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(),