Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/6836-node26-crypto-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fix(crypto): #6783 match Node 26 X509 `checkIP` errors and KMAC edge behavior, including non-byte-aligned lengths and empty JWK keys.
14 changes: 10 additions & 4 deletions crates/perry-runtime/src/buffer/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ fn notify_crypto_key_death(addr: usize) {
hook(addr);
}

pub type CryptoKeyMeta = (u8, u8, u8, bool, u32);
pub type CryptoKeyMeta = (u8, u8, u8, bool, u32, u32);

thread_local! {
static BUFFER_REGISTRY: RefCell<PtrHashSet<usize>> = RefCell::new(new_ptr_hash_set());
Expand Down Expand Up @@ -290,6 +290,7 @@ pub fn mark_as_crypto_key(addr: usize, algo: u8, hash: u8, kind: u8) {
kind,
true,
default_crypto_key_usages(algo, kind),
0,
);
}

Expand All @@ -300,10 +301,11 @@ pub fn mark_as_crypto_key_with_flags(
kind: u8,
extractable: bool,
usages: u32,
bit_length: u32,
) {
CRYPTO_KEY_META_REGISTRY.with(|r| {
r.borrow_mut()
.insert(addr, (algo, hash, kind, extractable, usages));
.insert(addr, (algo, hash, kind, extractable, usages, bit_length));
});
}

Expand All @@ -315,10 +317,11 @@ pub extern "C" fn js_buffer_mark_as_crypto_key_external(
kind: u8,
extractable: u8,
usages: u32,
bit_length: u32,
) {
register_buffer(addr as *const BufferHeader);
mark_as_uint8array(addr);
mark_as_crypto_key_with_flags(addr, algo, hash, kind, extractable != 0, usages);
mark_as_crypto_key_with_flags(addr, algo, hash, kind, extractable != 0, usages, bit_length);
// Latch BEFORE the insert — see js_buffer_register_external.
EXTERNAL_BUFFERS_NONEMPTY.store(true, std::sync::atomic::Ordering::Release);
if let Ok(mut r) = external_buffers().lock() {
Expand All @@ -328,7 +331,10 @@ pub extern "C" fn js_buffer_mark_as_crypto_key_external(
r.insert(addr);
}
if let Ok(mut r) = external_crypto_keys().lock() {
r.insert(addr, (algo, hash, kind, extractable != 0, usages));
r.insert(
addr,
(algo, hash, kind, extractable != 0, usages, bit_length),
);
}
}

Expand Down
10 changes: 6 additions & 4 deletions crates/perry-runtime/src/object/field_get_set/crypto_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ const CRYPTO_USAGE_ENCAPSULATE_KEY: u32 = 1 << 10;
const CRYPTO_USAGE_DECAPSULATE_KEY: u32 = 1 << 11;

pub(crate) unsafe fn crypto_key_property_value(addr: usize, key_bytes: &[u8]) -> Option<JSValue> {
let (algo, hash, kind, extractable, usages) = crate::buffer::crypto_key_meta(addr)?;
let (algo, hash, kind, extractable, usages, bit_length) = crate::buffer::crypto_key_meta(addr)?;
match key_bytes {
b"algorithm" => Some(crypto_key_algorithm_value(addr, algo, hash)),
b"algorithm" => Some(crypto_key_algorithm_value(addr, algo, hash, bit_length)),
b"extractable" => Some(JSValue::bool(extractable)),
b"type" => Some(string_value(match kind {
2 => "private",
Expand All @@ -41,7 +41,7 @@ pub(crate) unsafe fn crypto_key_property_value(addr: usize, key_bytes: &[u8]) ->
}
}

unsafe fn crypto_key_algorithm_value(addr: usize, algo: u8, hash: u8) -> JSValue {
unsafe fn crypto_key_algorithm_value(addr: usize, algo: u8, hash: u8, bit_length: u32) -> JSValue {
let obj = js_object_alloc(0, 3);
if obj.is_null() {
return JSValue::undefined();
Expand All @@ -56,7 +56,9 @@ unsafe fn crypto_key_algorithm_value(addr: usize, algo: u8, hash: u8) -> JSValue
}
if crypto_key_algorithm_has_length(algo) {
let key = addr as *const crate::buffer::BufferHeader;
let bits = if key.is_null() {
let bits = if bit_length != 0 {
bit_length as f64
} else if key.is_null() {
0.0
} else {
crate::buffer::js_buffer_length(key) as f64 * 8.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ fn conversion_failed(detail: &str) -> ! {
/// registry — never produced a KeyObject at all.
pub(super) unsafe fn key_object_from(value: f64) -> f64 {
let addr = value_addr(value);
let Some((_algo, _hash, kind, _extractable, _usages)) = crate::buffer::crypto_key_meta(addr)
let Some((_algo, _hash, kind, _extractable, _usages, _bit_length)) =
crate::buffer::crypto_key_meta(addr)
else {
invalid_key(value);
};
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-stdlib/src/crypto/x509.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ fn x509_check_ip_value(cert: &x509_cert::Certificate, ip: &str) -> Option<String

let parsed = ip.parse::<IpAddr>().unwrap_or_else(|_| {
perry_runtime::fs::validate::throw_type_error_with_code(
"Invalid IP",
"Invalid name",
"ERR_INVALID_ARG_VALUE",
)
});
Expand Down
3 changes: 0 additions & 3 deletions crates/perry-stdlib/src/webcrypto/hmac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,6 @@ unsafe fn kmac_output_length(algo_bits: u64) -> Result<u32, (&'static str, &'sta
let Some(length) = object_field_number(algo_bits, b"outputLength") else {
return Err(("TypeError", "KmacParams.outputLength is required"));
};
if length % 8 != 0 {
return Err(("NotSupportedError", "Unsupported KmacParams outputLength"));
}
Ok(length)
}

Expand Down
22 changes: 15 additions & 7 deletions crates/perry-stdlib/src/webcrypto/jwk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,16 +250,19 @@ pub unsafe extern "C" fn js_webcrypto_import_key(
}

let key_bytes = if format_lower == "jwk" {
jwk_import_key_bytes(key_bits.to_bits(), key_algo, kind).unwrap_or_else(|| Vec::new())
match jwk_import_key_bytes(key_bits.to_bits(), key_algo, kind) {
Some(bytes) => bytes,
None if matches!(key_algo, KeyAlgo::Kmac128 | KeyAlgo::Kmac256) => {
return reject_with_dom_exception("DataError", "Invalid keyData");
}
None => Vec::new(),
}
} else {
bytes_from_jsvalue(key_bits.to_bits())
};
if key_algo == KeyAlgo::ChaCha20Poly1305 && key_bytes.len() != 32 {
return reject_with_dom_exception("DataError", "Invalid key length");
}
if key_bytes.is_empty() && matches!(key_algo, KeyAlgo::Kmac128 | KeyAlgo::Kmac256) {
return reject_with_dom_exception("DataError", "Zero-length key is not supported");
}
if key_bytes.is_empty()
&& !matches!(
key_algo,
Expand All @@ -268,6 +271,8 @@ pub unsafe extern "C" fn js_webcrypto_import_key(
| KeyAlgo::Argon2d
| KeyAlgo::Argon2i
| KeyAlgo::Argon2id
| KeyAlgo::Kmac128
| KeyAlgo::Kmac256
Comment on lines +274 to +275

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)Cargo\.toml$|(^|/)CLAUDE\.md$|crates/perry-stdlib/src/webcrypto/jwk\.rs|changelog\.d/' || true

echo "== jwk relevant sections =="
if [ -f crates/perry-stdlib/src/webcrypto/jwk.rs ]; then
  wc -l crates/perry-stdlib/src/webcrypto/jwk.rs
  sed -n '200,310p' crates/perry-stdlib/src/webcrypto/jwk.rs
  echo "== k/import functions outline =="
  ast-grep outline crates/perry-stdlib/src/webcrypto/jwk.rs --view compact | sed -n '1,220p' || true
fi

echo "== search jwk_import_key_bytes =="
rg -n "jwk_import_key_bytes|KeyAlgo::Kmac|Kmac|k:" crates/perry-stdlib/src/webcrypto || true

Repository: PerryTS/perry

Length of output: 15088


🌐 Web query:

WebCrypto modern algorithms JWK k field decoding invalid JWK fail wicg github.io webcrypto modernalgos

💡 Result:

The "k" field in a JSON Web Key (JWK) is used to carry the raw key material for symmetric algorithms (typically with the "oct" key type) [1][2][3]. Decoding issues related to this field are generally caused by improper handling of Base64URL encoding—a variant of Base64 required by the JOSE specifications (RFC 7517) which replaces '+' with '-' and '/' with '', and omits padding [1][2]. In the context of the Web Cryptography API and its extensions (such as the Modern Algorithms proposal), "invalid JWK" errors during the decoding of the "k" field typically arise from: 1. Improper Encoding/Decoding: The "k" value must be decoded from its Base64URL representation into a raw byte array (Uint8Array) before it can be processed as a key by the WebCrypto subtle.importKey operation [1][3]. If an implementation attempts to treat the string directly as a key or uses standard Base64 decoding (which fails on characters like '-' and ''), a DataError or SyntaxError is standard behavior [1][2]. 2. Validation Requirements: Specifications and browser implementations enforce strict checks on the "k" field. For example, if the key length implied by the decoded "k" parameter does not match the requirements of the algorithm specified in the JWK (or provided to importKey), the operation will fail [4][2]. 3. WebCrypto "Modern Algorithms": The WICG "Modern Algorithms" specification [2] explicitly governs the handling of JWK inputs for new algorithms (like ML-KEM). Similar to the base WebCrypto API, it mandates that the "k" (or "priv"/"pub") fields must be valid Base64URL encoded data [2]. If a JWK is malformed—such as having an incorrect key type ("kty") for the algorithm, missing required fields, or having invalid Base64URL characters—the API is required to throw a DataError [2]. If you are encountering this error, ensure that: - The Base64URL string is correctly padded if your decoder requires it (though RFC 7517 suggests padding should be omitted) or, conversely, ensure your decoder is RFC-compliant and handles unpadded Base64URL correctly [1][2]. - The "kty" (Key Type) and "alg" (Algorithm) fields in your JWK object align with the WebCrypto algorithm you are importing into [2][3]. - You are not passing the JWK object directly as a key material buffer, but rather passing the parsed JWK structure to the importKey function where the browser/runtime handles the internal decoding [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== jwk.rs 720-820 =="
sed -n '720,820p' crates/perry-stdlib/src/webcrypto/jwk.rs

echo "== jwk.rs 820-940 for validate/import key bits =="
sed -n '820,940p' crates/perry-stdlib/src/webcrypto/jwk.rs

echo "== jwk.rs 428-470 and 508-535 for export KMAC =="
sed -n '428,470p' crates/perry-stdlib/src/webcrypto/jwk.rs
sed -n '508,535p' crates/perry-stdlib/src/webcrypto/jwk.rs

echo "== imports/top =="
sed -n '1,90p' crates/perry-stdlib/src/webcrypto/jwk.rs

Repository: PerryTS/perry

Length of output: 14707


Do not allow malformed JWK k decoding as an empty KMAC key.

jwk_import_key_bytes(...).unwrap_or_else(|| Vec::new()) converts a missing or invalid decoded k value into the same zero-length byte vector as an explicitly present empty JWK. Since KMAC is now in the empty-key allowlist, malformed JWKs can be imported as zero-length keys instead of failing key-material validation. Preserve the decode result: allow k: "" only when explicit, and reject missing or invalid k data.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-stdlib/src/webcrypto/jwk.rs` around lines 268 - 269, The KMAC
branches in key import must preserve whether the JWK k value was explicitly
present and valid instead of collapsing missing or invalid decoding into an
empty vector. Update the jwk_import_key_bytes handling used by KeyAlgo::Kmac128
and KeyAlgo::Kmac256 to reject absent or malformed k data, while still allowing
an explicitly provided k: "" value.

)
{
return reject_with_dom_exception("DataError", "Key data is empty or could not be read");
Expand Down Expand Up @@ -801,9 +806,12 @@ pub(super) unsafe fn jwk_import_key_bytes(
}
}
let k = object_field_string(obj_bits, b"k")?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(k.as_bytes())
.ok()?;
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(k.as_bytes());
let bytes = if matches!(key_algo, KeyAlgo::Kmac128 | KeyAlgo::Kmac256) {
decoded.unwrap_or_default()
} else {
decoded.ok()?
};
if key_algo == KeyAlgo::AesOcb {
if let Some(alg) = object_field_string(obj_bits, b"alg") {
if let Some(expected) = aes_ocb_jwk_alg(bytes.len()) {
Expand Down
16 changes: 8 additions & 8 deletions crates/perry-stdlib/src/webcrypto/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -657,20 +657,19 @@ pub unsafe extern "C" fn js_webcrypto_generate_key(
"KmacKeyGenParams.length cannot be 0",
);
}
if bit_len % 8 != 0 {
return reject_with_dom_exception(
"NotSupportedError",
"Unsupported KmacKeyGenParams.length",
);
}
let mut key_bytes = vec![0u8; (bit_len / 8) as usize];
let mut key_bytes = vec![0u8; bit_len.div_ceil(8) as usize];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
use rand::RngCore;
rand::rngs::OsRng.fill_bytes(&mut key_bytes);
if bit_len % 8 != 0 {
if let Some(last) = key_bytes.last_mut() {
*last &= 0xFF << (8 - bit_len % 8);
}
}
let buf = alloc_uint8array_from_slice(&key_bytes);
if buf.is_null() {
return reject_with_dom_exception("OperationError", "The operation failed");
}
register_crypto_key(
register_crypto_key_with_bit_length(
buf as usize,
CryptoKeyMaterial::new(
key_algo,
Expand All @@ -679,6 +678,7 @@ pub unsafe extern "C" fn js_webcrypto_generate_key(
extractable,
usages,
),
bit_len,
);
return resolve_with_bits(JSValue::pointer(buf as *const u8).bits());
}
Expand Down
17 changes: 12 additions & 5 deletions crates/perry-stdlib/src/webcrypto/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ extern "C" {
kind: u8,
extractable: u8,
usages: u32,
bit_length: u32,
);
}

Expand Down Expand Up @@ -280,6 +281,14 @@ pub(super) static CRYPTO_KEY_REGISTRY: Lazy<Mutex<HashMap<usize, CryptoKeyMateri
Lazy::new(|| Mutex::new(HashMap::new()));

pub(super) fn register_crypto_key(buf_addr: usize, mat: CryptoKeyMaterial) {
register_crypto_key_with_bit_length(buf_addr, mat, 0);
}

pub(super) fn register_crypto_key_with_bit_length(
buf_addr: usize,
mat: CryptoKeyMaterial,
bit_length: u32,
) {
CRYPTO_KEY_REGISTRY.lock().unwrap().insert(buf_addr, mat);
unsafe {
js_buffer_mark_as_crypto_key_external(
Expand All @@ -289,6 +298,7 @@ pub(super) fn register_crypto_key(buf_addr: usize, mat: CryptoKeyMaterial) {
runtime_key_kind_id(mat.kind),
u8::from(mat.extractable),
mat.usages,
bit_length,
);
}
}
Expand Down Expand Up @@ -367,7 +377,7 @@ pub(super) fn lookup_crypto_key(buf_addr: usize) -> Option<CryptoKeyMaterial> {
.get(&buf_addr)
.copied()
.or_else(|| {
let (algo, hash, kind, extractable, usages) =
let (algo, hash, kind, extractable, usages, _bit_length) =
perry_runtime::buffer::crypto_key_meta(buf_addr)?;
let algo = match algo {
1 => KeyAlgo::Hmac,
Expand Down Expand Up @@ -973,10 +983,7 @@ pub(super) fn compute_kmac(
data: &[u8],
output_bits: u32,
) -> Option<Vec<u8>> {
if output_bits % 8 != 0 {
return None;
}
let mut out = vec![0u8; (output_bits / 8) as usize];
let mut out = vec![0u8; output_bits.div_ceil(8) as usize];
match algo {
KeyAlgo::Kmac128 => {
use sha3_010::digest::{core_api::CoreProxy, ExtendableOutput, Update, XofReader};
Expand Down
7 changes: 6 additions & 1 deletion test-parity/node-suite/crypto/webcrypto/kmac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ async function main() {
await logReject("kmac128 bad usage", crypto.subtle.generateKey("KMAC128", true, ["encrypt" as any]));
await logReject("kmac128 bad key length", crypto.subtle.generateKey({ name: "KMAC128", length: 7 }, true, ["sign"]));
await logReject("kmac128 zero key", crypto.subtle.importKey("jwk", { kty: "oct", alg: "K128", k: "", ext: true, key_ops: ["sign"] }, "KMAC128", true, ["sign"]));
await logReject("kmac128 bad output", crypto.subtle.sign({ name: "KMAC128", outputLength: 7 }, key128, data));
await logReject("kmac128 missing jwk key", crypto.subtle.importKey("jwk", { kty: "oct", alg: "K128", ext: true, key_ops: ["sign"] }, "KMAC128", true, ["sign"]));
const shortKey = await crypto.subtle.generateKey({ name: "KMAC128", length: 7 }, true, ["sign"]);
const shortJwk: any = await crypto.subtle.exportKey("jwk", shortKey);
console.log("kmac128 short key:", (shortKey.algorithm as any).length, Buffer.from(shortJwk.k, "base64url")[0] & 1);
const shortSig = await crypto.subtle.sign({ name: "KMAC128", outputLength: 7 }, key128, data);
console.log("kmac128 short output:", Buffer.from(shortSig).toString("hex"));
}

await main();
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ console.log("typeof toLegacyObject:", typeof toLegacy);
if (typeof toLegacy !== "function") {
console.log("legacy unavailable");
} else {
const legacy = toLegacy();
const legacy = cert["toLegacyObject"]();
console.log("legacy subject CN:", legacy["subject"]["CN"]);
console.log("legacy issuer CN:", legacy["issuer"]["CN"]);
console.log("legacy ca:", legacy["ca"]);
Expand Down
Loading