diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c0536..e7d222c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +- Added a builtin API for hashing and calculating HMACs as part of the `serde` library + + Basic usage: + + ```lua + local serde = require("@lune/serde") + local hash = serde.hash("sha256", "a message to hash") + local hmac = serde.hmac("sha256", "a message to hash", "a secret string") + + print(hash) + print(hmac) + ``` + + The returned hashes are sequences of lowercase hexadecimal digits. The following algorithms are supported: + `md5`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`, `sha3-224`, `sha3-256`, `sha3-384`, `sha3-512`, `blake3` + ## `0.8.5` - June 1st, 2024 ### Changed diff --git a/Cargo.lock b/Cargo.lock index e21c206..13ae8cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,6 +273,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq 0.3.0", + "digest", ] [[package]] @@ -1339,6 +1340,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1582,13 +1592,20 @@ name = "lune-std-serde" version = "0.1.0" dependencies = [ "async-compression", + "blake3", "bstr", + "digest", + "hmac", "lune-utils", "lz4", + "md-5", "mlua", "serde", "serde_json", "serde_yaml", + "sha1 0.10.6", + "sha2", + "sha3", "tokio", "toml", ] @@ -1666,6 +1683,16 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.2" @@ -2665,6 +2692,27 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/crates/lune-std-serde/Cargo.toml b/crates/lune-std-serde/Cargo.toml index 91786ff..ab7bec0 100644 --- a/crates/lune-std-serde/Cargo.toml +++ b/crates/lune-std-serde/Cargo.toml @@ -29,6 +29,16 @@ serde_json = { version = "1.0", features = ["preserve_order"] } serde_yaml = "0.9" toml = { version = "0.8", features = ["preserve_order"] } +digest = "0.10.7" +hmac = "0.12.1" +md-5 = "0.10.6" +sha1 = "0.10.6" +sha2 = "0.10.8" +sha3 = "0.10.8" +# This feature MIGHT break due to the unstable nature of the digest crate. +# Check before updating it. +blake3 = { version = "1.5.0", features = ["traits-preview"] } + tokio = { version = "1", default-features = false, features = [ "rt", "io-util", diff --git a/crates/lune-std-serde/src/hash.rs b/crates/lune-std-serde/src/hash.rs new file mode 100644 index 0000000..cf0d3c6 --- /dev/null +++ b/crates/lune-std-serde/src/hash.rs @@ -0,0 +1,234 @@ +use std::fmt::Write; + +use bstr::BString; +use md5::Md5; +use mlua::prelude::*; + +use blake3::Hasher as Blake3; +use sha1::Sha1; +use sha2::{Sha224, Sha256, Sha384, Sha512}; +use sha3::{Sha3_224, Sha3_256, Sha3_384, Sha3_512}; + +pub struct HashOptions { + algorithm: HashAlgorithm, + message: BString, + secret: Option, + // seed: Option, +} + +#[derive(Debug, Clone, Copy)] +enum HashAlgorithm { + Md5, + Sha1, + // SHA-2 variants + Sha2_224, + Sha2_256, + Sha2_384, + Sha2_512, + // SHA-3 variants + Sha3_224, + Sha3_256, + Sha3_384, + Sha3_512, + // Blake3 + Blake3, +} + +impl HashAlgorithm { + pub fn list_all_as_string() -> String { + [ + "md5", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3-224", "sha3-256", + "sha3-384", "sha3-512", "blake3", + ] + .join(", ") + } +} + +impl HashOptions { + /** + Computes the hash for the `message` using whatever `algorithm` is + contained within this struct and returns it as a string of hex digits. + */ + #[inline] + #[must_use = "hashing a message is useless without using the resulting hash"] + pub fn hash(self) -> String { + use digest::Digest; + + let message = self.message; + let bytes = match self.algorithm { + HashAlgorithm::Md5 => Md5::digest(message).to_vec(), + HashAlgorithm::Sha1 => Sha1::digest(message).to_vec(), + HashAlgorithm::Sha2_224 => Sha224::digest(message).to_vec(), + HashAlgorithm::Sha2_256 => Sha256::digest(message).to_vec(), + HashAlgorithm::Sha2_384 => Sha384::digest(message).to_vec(), + HashAlgorithm::Sha2_512 => Sha512::digest(message).to_vec(), + + HashAlgorithm::Sha3_224 => Sha3_224::digest(message).to_vec(), + HashAlgorithm::Sha3_256 => Sha3_256::digest(message).to_vec(), + HashAlgorithm::Sha3_384 => Sha3_384::digest(message).to_vec(), + HashAlgorithm::Sha3_512 => Sha3_512::digest(message).to_vec(), + + HashAlgorithm::Blake3 => Blake3::digest(message).to_vec(), + }; + + // We don't want to return raw binary data generally, since that's not + // what most people want a hash for. So we have to make a hex string. + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut output, b| { + let _ = write!(output, "{b:02x}"); + output + }) + } + + /** + Computes the HMAC for the `message` using whatever `algorithm` and + `secret` are contained within this struct. The computed value is + returned as a string of hex digits. + + # Errors + + If the `secret` is not provided or is otherwise invalid. + */ + #[inline] + pub fn hmac(self) -> LuaResult { + use hmac::{Hmac, Mac, SimpleHmac}; + + let secret = self + .secret + .ok_or_else(|| LuaError::FromLuaConversionError { + from: "nil", + to: "string or buffer", + message: Some("Argument #3 missing or nil".to_string()), + })?; + + /* + These macros exist to remove what would ultimately be dozens of + repeating lines. Essentially, there's several step to processing + HMacs, which expands into the 3 lines you see below. However, + the Hmac struct is specialized towards eager block-based processes. + In order to support anything else, like blake3, there's a second + type named `SimpleHmac`. This results in duplicate macros like + there are below. + */ + macro_rules! hmac { + ($Type:ty) => {{ + let mut mac: Hmac<$Type> = Hmac::new_from_slice(&secret).into_lua_err()?; + mac.update(&self.message); + mac.finalize().into_bytes().to_vec() + }}; + } + macro_rules! hmac_no_blocks { + ($Type:ty) => {{ + let mut mac: SimpleHmac<$Type> = + SimpleHmac::new_from_slice(&secret).into_lua_err()?; + mac.update(&self.message); + mac.finalize().into_bytes().to_vec() + }}; + } + + let bytes = match self.algorithm { + HashAlgorithm::Md5 => hmac!(Md5), + HashAlgorithm::Sha1 => hmac!(Sha1), + + HashAlgorithm::Sha2_224 => hmac!(Sha224), + HashAlgorithm::Sha2_256 => hmac!(Sha256), + HashAlgorithm::Sha2_384 => hmac!(Sha384), + HashAlgorithm::Sha2_512 => hmac!(Sha512), + + HashAlgorithm::Sha3_224 => hmac!(Sha3_224), + HashAlgorithm::Sha3_256 => hmac!(Sha3_256), + HashAlgorithm::Sha3_384 => hmac!(Sha3_384), + HashAlgorithm::Sha3_512 => hmac!(Sha3_512), + + HashAlgorithm::Blake3 => hmac_no_blocks!(Blake3), + }; + Ok(bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut output, b| { + let _ = write!(output, "{b:02x}"); + output + })) + } +} + +impl<'lua> FromLua<'lua> for HashAlgorithm { + fn from_lua(value: LuaValue<'lua>, _lua: &'lua Lua) -> LuaResult { + if let LuaValue::String(str) = value { + /* + Casing tends to vary for algorithms, so rather than force + people to remember it we'll just accept any casing. + */ + let str = str.to_str()?.to_ascii_lowercase(); + match str.as_str() { + "md5" => Ok(Self::Md5), + "sha1" => Ok(Self::Sha1), + + "sha224" => Ok(Self::Sha2_224), + "sha256" => Ok(Self::Sha2_256), + "sha384" => Ok(Self::Sha2_384), + "sha512" => Ok(Self::Sha2_512), + + "sha3-224" => Ok(Self::Sha3_224), + "sha3-256" => Ok(Self::Sha3_256), + "sha3-384" => Ok(Self::Sha3_384), + "sha3-512" => Ok(Self::Sha3_512), + + "blake3" => Ok(Self::Blake3), + + _ => Err(LuaError::FromLuaConversionError { + from: "string", + to: "HashAlgorithm", + message: Some(format!( + "Invalid hashing algorithm '{str}', valid kinds are:\n{}", + HashAlgorithm::list_all_as_string() + )), + }), + } + } else { + Err(LuaError::FromLuaConversionError { + from: value.type_name(), + to: "HashAlgorithm", + message: None, + }) + } + } +} + +impl<'lua> FromLuaMulti<'lua> for HashOptions { + fn from_lua_multi(mut values: LuaMultiValue<'lua>, lua: &'lua Lua) -> LuaResult { + let algorithm = values + .pop_front() + .map(|value| HashAlgorithm::from_lua(value, lua)) + .transpose()? + .ok_or_else(|| LuaError::FromLuaConversionError { + from: "nil", + to: "HashAlgorithm", + message: Some("Argument #1 missing or nil".to_string()), + })?; + let message = values + .pop_front() + .map(|value| BString::from_lua(value, lua)) + .transpose()? + .ok_or_else(|| LuaError::FromLuaConversionError { + from: "nil", + to: "string or buffer", + message: Some("Argument #2 missing or nil".to_string()), + })?; + let secret = values + .pop_front() + .map(|value| BString::from_lua(value, lua)) + .transpose()?; + // let seed = values + // .pop_front() + // .map(|value| BString::from_lua(value, lua)) + // .transpose()?; + + Ok(HashOptions { + algorithm, + message, + secret, + // seed, + }) + } +} diff --git a/crates/lune-std-serde/src/lib.rs b/crates/lune-std-serde/src/lib.rs index 4514a75..4a66adf 100644 --- a/crates/lune-std-serde/src/lib.rs +++ b/crates/lune-std-serde/src/lib.rs @@ -7,9 +7,11 @@ use lune_utils::TableBuilder; mod compress_decompress; mod encode_decode; +mod hash; pub use self::compress_decompress::{compress, decompress, CompressDecompressFormat}; pub use self::encode_decode::{decode, encode, EncodeDecodeConfig, EncodeDecodeFormat}; +pub use self::hash::HashOptions; /** Creates the `serde` standard library module. @@ -24,6 +26,8 @@ pub fn module(lua: &Lua) -> LuaResult { .with_function("decode", serde_decode)? .with_async_function("compress", serde_compress)? .with_async_function("decompress", serde_decompress)? + .with_function("hash", hash_message)? + .with_function("hmac", hmac_message)? .build_readonly() } @@ -55,3 +59,11 @@ async fn serde_decompress( let bytes = decompress(bs, format).await?; lua.create_string(bytes) } + +fn hash_message(lua: &Lua, options: HashOptions) -> LuaResult { + lua.create_string(options.hash()) +} + +fn hmac_message(lua: &Lua, options: HashOptions) -> LuaResult { + lua.create_string(options.hmac()?) +} diff --git a/crates/lune/src/tests.rs b/crates/lune/src/tests.rs index de726ce..0306b29 100644 --- a/crates/lune/src/tests.rs +++ b/crates/lune/src/tests.rs @@ -230,6 +230,8 @@ create_tests! { serde_json_encode: "serde/json/encode", serde_toml_decode: "serde/toml/decode", serde_toml_encode: "serde/toml/encode", + serde_hashing_hash: "serde/hashing/hash", + serde_hashing_hmac: "serde/hashing/hmac", } #[cfg(feature = "std-stdio")] diff --git a/tests/serde/hashing/hash.luau b/tests/serde/hashing/hash.luau new file mode 100644 index 0000000..c0d5a27 --- /dev/null +++ b/tests/serde/hashing/hash.luau @@ -0,0 +1,48 @@ +local serde = require("@lune/serde") + +local TEST_INPUT = + "Luau is a fast, small, safe, gradually typed embeddable scripting language derived from Lua." + +local function test_case_hash(algorithm: serde.HashAlgorithm, expected: string) + assert( + serde.hash(algorithm, TEST_INPUT) == expected, + `hashing algorithm '{algorithm}' did not hash test string correctly` + ) + assert( + serde.hash(algorithm, buffer.fromstring(TEST_INPUT)) == expected, + `hashing algorithm '{algorithm}' did not hash test buffer correctly` + ) +end + +test_case_hash("blake3", "eccfe3a6696b2a1861c64cc78663cff51301058e5dc22bb6249e7e1e0173d7fe") +test_case_hash("md5", "2aed9e020b49d219dc383884c5bd7acd") +test_case_hash("sha1", "9dce74190857f36e6d3f5e8eb7fe704a74060726") +test_case_hash("sha224", "f7ccd8a5f2697df8470b66f03824e073075292a1fab40d3a2ddc2e83") +test_case_hash("sha256", "f1d149bfd1ea38833ae6abf2a6fece1531532283820d719272e9cf3d9344efea") +test_case_hash( + "sha384", + "f6da4b47846c6016a9b32f01b861e45195cf1fa6fc5c9dd2257f7dc1c14092f11001839ec1223c30ab7adb7370812863" +) +test_case_hash( + "sha512", + "49fd834fdf3d4eaf4d4aff289acfc24d649f81cee7a5a7940e5c86854e04816f0a97c53f2ca4908969a512ec5ad1dc466422e3928f5ce3da9913959315df807c" +) +test_case_hash("sha3-224", "56a4dd1ff1bd9baff7f8bbe380dbf2c75b073161693f94ebf91aeee5") +test_case_hash("sha3-256", "ee01be10e0dc133cd702999e854b396f40b039d5ba6ddec9d04bf8623ba04dd7") +test_case_hash( + "sha3-384", + "e992f31e638b47802f33a4327c0a951823e32491ddcef5af9ce18cff84475c98ced23928d47ef51a8a4299dfe2ece361" +) +test_case_hash( + "sha3-512", + "08bd02aca3052b7740de80b8e8b9969dc9059a4bfae197095430e0aa204fbd3afb11731b127559b90c2f7e295835ea844ddbb29baf2fdb1d823046052c120fc9" +) + +local failed = pcall(serde.hash, "a random string" :: any, "input that shouldn't be hashed") +assert(failed == false, "serde.hash shouldn't allow invalid algorithms passed to it!") + +assert( + serde.hash("sha256", "\0oh no invalid utf-8\127\0\255") + == "c18ed3188f9e93f9ecd3582d7398c45120b0b30a0e26243809206228ab711b78", + "serde.hash should hash invalid UTF-8 just fine" +) diff --git a/tests/serde/hashing/hmac.luau b/tests/serde/hashing/hmac.luau new file mode 100644 index 0000000..0af7c23 --- /dev/null +++ b/tests/serde/hashing/hmac.luau @@ -0,0 +1,60 @@ +local serde = require("@lune/serde") + +local INPUT_STRING = "important data to verify the integrity of" + +-- if you read this string, you're obligated to keep it a secret! :-) +local SECRET_STRING = "don't read this we operate on the honor system" + +local function test_case_hmac(algorithm: serde.HashAlgorithm, expected: string) + assert( + serde.hmac(algorithm, INPUT_STRING, SECRET_STRING) == expected, + `HMAC test for algorithm '{algorithm}' was not correct with string input and string secret` + ) + assert( + serde.hmac(algorithm, INPUT_STRING, buffer.fromstring(SECRET_STRING)) == expected, + `HMAC test for algorithm '{algorithm}' was not correct with string input and buffer secret` + ) + assert( + serde.hmac(algorithm, buffer.fromstring(INPUT_STRING), SECRET_STRING) == expected, + `HMAC test for algorithm '{algorithm}' was not correct with buffer input and string secret` + ) + assert( + serde.hmac(algorithm, buffer.fromstring(INPUT_STRING), buffer.fromstring(SECRET_STRING)) + == expected, + `HMAC test for algorithm '{algorithm}' was not correct with buffer input and buffer secret` + ) +end + +test_case_hmac("blake3", "1d9c1b9405567fc565c2c3c6d6c0e170be72a2623d29911f43cb2ce42a373c01") +test_case_hmac("md5", "525379669c93ab5f59d2201024145b79") +test_case_hmac("sha1", "75227c11ed65133788feab0ce7eb8efc8c1f0517") +test_case_hmac("sha224", "47a4857d7d7e1070f47f76558323e03471a918facaf3667037519c29") +test_case_hmac("sha256", "4a4816ab8d4b780a8cf131e34a3df25e4c7bc4eba453cd86e50271aab4e95f45") +test_case_hmac( + "sha384", + "6b24aeae78d0f84ec8a4669b24bda1131205535233c344f4262c1f90f29af04c5537612c269bbab8aaca9d8293f4a280" +) +test_case_hmac( + "sha512", + "9fffa071241e2f361f8a47a97d251c1d4aae37498efbc49745bf9916d8431f1f361080d350067ed65744d3da42956da33ec57b04901a5fd63a891381a1485ef7" +) +test_case_hmac("sha3-224", "ea102dfaa74aa285555bdba29a04429dfd4e997fa40322459094929f") +test_case_hmac("sha3-256", "17bde287e4692e5b7f281e444efefe92e00696a089570bd6814fd0e03d7763d2") +test_case_hmac( + "sha3-384", + "24f68401653d25f36e7ee8635831215f8b46710d4e133c9d1e091e5972c69b0f1d0cb80f5507522fa174d5c4746963c1" +) +test_case_hmac( + "sha3-512", + "d2566d156c254ced0101159f97187dbf48d900b8361fa5ebdd7e81409856b1b6a21d93a1fb6e8f700e75620d244ab9e894454030da12d158e9362ffe090d2669" +) + +local failed = + pcall(serde.hmac, "a random string" :: any, "input that shouldn't be hashed", "not a secret") +assert(failed == false, "serde.hmac shouldn't allow invalid algorithms passed to it!") + +assert( + serde.hmac("sha256", "\0oh no invalid utf-8\127\0\255", SECRET_STRING) + == "1f0d7f65016e9e4c340e3ba23da2483a7dc101ce8a9405f834c23f2e19232c3d", + "serde.hmac should hash invalid UTF-8 just fine" +) diff --git a/types/serde.luau b/types/serde.luau index c4a21d8..ff12714 100644 --- a/types/serde.luau +++ b/types/serde.luau @@ -2,6 +2,19 @@ export type EncodeDecodeFormat = "json" | "yaml" | "toml" export type CompressDecompressFormat = "brotli" | "gzip" | "lz4" | "zlib" +export type HashAlgorithm = + "md5" + | "sha1" + | "sha224" + | "sha256" + | "sha384" + | "sha512" + | "sha3-224" + | "sha3-256" + | "sha3-384" + | "sha3-512" + | "blake3" + --[=[ @class Serde @@ -120,4 +133,16 @@ function serde.decompress(format: CompressDecompressFormat, s: buffer | string): return nil :: any end +function serde.hash(algorithm: HashAlgorithm, message: string | buffer): string + return nil :: any +end + +function serde.hmac( + algorithm: HashAlgorithm, + message: string | buffer, + secret: string | buffer +): string + return nil :: any +end + return serde