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:
1 parent
d5d9b7788f
commit
ed75730e2d
4 files changed
+705
-27
No files matched your search
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user