diff --git a/changelog.d/11029-crypto-latin1-digests.md b/changelog.d/11029-crypto-latin1-digests.md new file mode 100644 index 0000000000..f1c15f5a20 --- /dev/null +++ b/changelog.d/11029-crypto-latin1-digests.md @@ -0,0 +1,6 @@ +### Fixed + +- `Hash.digest()` and `Hmac.digest()` now preserve every digest byte when + called with `"latin1"` or its `"binary"` alias. Bytes above `0x7f` no longer + become replacement characters, and the resulting string round-trips through + `Buffer.from(value, "latin1")` without corruption or length changes. diff --git a/crates/perry-stdlib/src/crypto/hash_handles.rs b/crates/perry-stdlib/src/crypto/hash_handles.rs index 1632e77a7b..a947690f0e 100644 --- a/crates/perry-stdlib/src/crypto/hash_handles.rs +++ b/crates/perry-stdlib/src/crypto/hash_handles.rs @@ -179,12 +179,16 @@ fn finalize_hmac_state(state: Option) -> Vec { } } +fn latin1_string(bytes: &[u8]) -> String { + bytes.iter().map(|&byte| char::from(byte)).collect() +} + fn encoded_digest(bytes: &[u8], encoding: &str) -> String { match encoding { "hex" => hex::encode(bytes), "base64" => base64::engine::general_purpose::STANDARD.encode(bytes), "base64url" => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes), - "binary" | "latin1" => String::from_utf8_lossy(bytes).into_owned(), + "binary" | "latin1" => latin1_string(bytes), _ => String::from_utf8_lossy(bytes).into_owned(), } } @@ -498,7 +502,7 @@ pub unsafe fn dispatch_hash(handle: i64, method: &str, args: &[f64]) -> f64 { "hex" => hex::encode(&digest), "base64" => base64::engine::general_purpose::STANDARD.encode(&digest), "base64url" => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&digest), - "binary" | "latin1" => String::from_utf8_lossy(&digest).into_owned(), + "binary" | "latin1" => latin1_string(&digest), _ => hex::encode(&digest), }; let s = js_string_from_bytes(encoded.as_ptr(), encoded.len() as u32); @@ -754,7 +758,7 @@ pub unsafe fn dispatch_hmac(handle: i64, method: &str, args: &[f64]) -> f64 { "hex" => hex::encode(&digest), "base64" => base64::engine::general_purpose::STANDARD.encode(&digest), "base64url" => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&digest), - "binary" | "latin1" => String::from_utf8_lossy(&digest).into_owned(), + "binary" | "latin1" => latin1_string(&digest), _ => hex::encode(&digest), }; let s = js_string_from_bytes(encoded.as_ptr(), encoded.len() as u32); diff --git a/test-files/test_gap_10473_crypto_digest_latin1.ts b/test-files/test_gap_10473_crypto_digest_latin1.ts new file mode 100644 index 0000000000..78ae49a57c --- /dev/null +++ b/test-files/test_gap_10473_crypto_digest_latin1.ts @@ -0,0 +1,15 @@ +import crypto from "node:crypto"; + +function report(label: string, value: string, expectedHex: string) { + const codes = Array.from(value.slice(0, 6), (char) => char.charCodeAt(0)).join(","); + const roundTrips = Buffer.from(value, "latin1").toString("hex") === expectedHex; + console.log(label, value.length, codes, roundTrips); +} + +const hashHex = crypto.createHash("sha256").update("abc").digest("hex"); +report("hash latin1", crypto.createHash("sha256").update("abc").digest("latin1"), hashHex); +report("hash binary", crypto.createHash("sha256").update("abc").digest("binary"), hashHex); + +const hmacHex = crypto.createHmac("sha256", "k").update("abc").digest("hex"); +report("hmac latin1", crypto.createHmac("sha256", "k").update("abc").digest("latin1"), hmacHex); +report("hmac binary", crypto.createHmac("sha256", "k").update("abc").digest("binary"), hmacHex);