use pbkdf2::pbkdf2_hmac;
use rquickjs::prelude::Func;
use scrypt::{Params as ScryptParams, scrypt};
use sha2::{Digest, Sha256};
use uuid::Builder;
const KDF_MAX_OUTPUT_BYTES: usize = 1_048_576; const KDF_MAX_PBKDF2_ITERATIONS: u32 = 1_000_000;
const KDF_MAX_SCRYPT_LOG_N: u8 = 20; const KDF_MAX_SCRYPT_R: u32 = 16;
const KDF_MAX_SCRYPT_P: u32 = 16;
const KDF_MAX_SCRYPT_MEM_BYTES: usize = 32 * 1024 * 1024;
pub fn register_crypto_hostcalls(global: &rquickjs::Object<'_>) -> rquickjs::Result<()> {
register_hash_hostcall(global)?;
register_hmac_hostcall(global)?;
register_uuid_hostcall(global)?;
register_random_int_hostcall(global)?;
register_random_bytes_hostcall(global)?;
register_timing_safe_equal_hostcall(global)?;
register_pbkdf2_hostcall(global)?;
register_scrypt_hostcall(global)?;
Ok(())
}
fn register_hash_hostcall(global: &rquickjs::Object<'_>) -> rquickjs::Result<()> {
global.set(
"__pi_crypto_hash_native",
Func::from(
|algorithm: String,
data: rquickjs::TypedArray<'_, u8>,
encoding: String|
-> rquickjs::Result<String> {
let bytes = data
.as_bytes()
.ok_or_else(|| rquickjs::Error::new_from_js("buffer", "Detached buffer"))?;
let hash_bytes: Vec<u8> = match algorithm.as_str() {
"sha256" => {
let mut h = Sha256::new();
h.update(bytes);
h.finalize().to_vec()
}
"sha512" => {
let mut h = sha2::Sha512::new();
h.update(bytes);
h.finalize().to_vec()
}
"sha1" => {
let mut h = sha1::Sha1::new();
h.update(bytes);
h.finalize().to_vec()
}
"md5" => {
let mut h = md5::Md5::new();
h.update(bytes);
h.finalize().to_vec()
}
_ => {
return Err(rquickjs::Error::new_from_js(
"string",
"unsupported hash algorithm",
));
}
};
Ok(encode_output(&hash_bytes, &encoding))
},
),
)
}
fn register_hmac_hostcall(global: &rquickjs::Object<'_>) -> rquickjs::Result<()> {
global.set(
"__pi_crypto_hmac_native",
Func::from(
|algorithm: String,
key: rquickjs::TypedArray<'_, u8>,
data: rquickjs::TypedArray<'_, u8>,
encoding: String|
-> rquickjs::Result<String> {
use hmac::Mac;
let key_bytes = key
.as_bytes()
.ok_or_else(|| rquickjs::Error::new_from_js("buffer", "Detached key buffer"))?;
let data_bytes = data.as_bytes().ok_or_else(|| {
rquickjs::Error::new_from_js("buffer", "Detached data buffer")
})?;
let hash_bytes = match algorithm.as_str() {
"sha256" => {
let mut mac =
hmac::Hmac::<Sha256>::new_from_slice(key_bytes).map_err(|_| {
rquickjs::Error::new_from_js("key", "invalid HMAC key length")
})?;
mac.update(data_bytes);
mac.finalize().into_bytes().to_vec()
}
"sha512" => {
let mut mac = hmac::Hmac::<sha2::Sha512>::new_from_slice(key_bytes)
.map_err(|_| {
rquickjs::Error::new_from_js("key", "invalid HMAC key length")
})?;
mac.update(data_bytes);
mac.finalize().into_bytes().to_vec()
}
"sha1" => {
let mut mac =
hmac::Hmac::<sha1::Sha1>::new_from_slice(key_bytes).map_err(|_| {
rquickjs::Error::new_from_js("key", "invalid HMAC key length")
})?;
mac.update(data_bytes);
mac.finalize().into_bytes().to_vec()
}
"md5" => {
let mut mac =
hmac::Hmac::<md5::Md5>::new_from_slice(key_bytes).map_err(|_| {
rquickjs::Error::new_from_js("key", "invalid HMAC key length")
})?;
mac.update(data_bytes);
mac.finalize().into_bytes().to_vec()
}
_ => {
return Err(rquickjs::Error::new_from_js(
"string",
"unsupported HMAC algorithm",
));
}
};
Ok(encode_output(&hash_bytes, &encoding))
},
),
)
}
fn register_uuid_hostcall(global: &rquickjs::Object<'_>) -> rquickjs::Result<()> {
global.set(
"__pi_crypto_random_uuid_native",
Func::from(|| -> rquickjs::Result<String> {
random_uuid().map_err(|err| map_entropy_error("randomUUID", err))
}),
)
}
fn register_random_int_hostcall(global: &rquickjs::Object<'_>) -> rquickjs::Result<()> {
global.set(
"__pi_crypto_random_int_native",
Func::from(|min: f64, max: f64| -> rquickjs::Result<f64> {
if !min.is_finite() || !max.is_finite() {
return Err(rquickjs::Error::new_from_js(
"number",
"min and max must be finite numbers",
));
}
if min >= max {
return Err(rquickjs::Error::new_from_js(
"number",
"min must be less than max",
));
}
let range = max - min;
let rand_bytes = random_bytes(8).map_err(|err| map_entropy_error("randomInt", err))?;
let mut random_window = [0_u8; 8];
random_window.copy_from_slice(&rand_bytes);
let random = u64::from_le_bytes(random_window) >> 11;
#[allow(clippy::cast_precision_loss)]
let normalized = (random as f64) / ((1u64 << 53) as f64);
Ok(min + (normalized * range).floor())
}),
)
}
fn register_random_bytes_hostcall(global: &rquickjs::Object<'_>) -> rquickjs::Result<()> {
global.set(
"__pi_crypto_random_bytes_native",
Func::from(|size: usize| -> rquickjs::Result<String> {
if size > 10 * 1024 * 1024 {
return Err(rquickjs::Error::new_from_js(
"number",
"randomBytes size limit exceeded (max 10MB)",
));
}
let bytes = random_bytes(size).map_err(|err| map_entropy_error("randomBytes", err))?;
Ok(hex_lower(&bytes))
}),
)
}
fn register_timing_safe_equal_hostcall(global: &rquickjs::Object<'_>) -> rquickjs::Result<()> {
global.set(
"__pi_crypto_timing_safe_equal_native",
Func::from(
|a: rquickjs::TypedArray<'_, u8>,
b: rquickjs::TypedArray<'_, u8>|
-> rquickjs::Result<bool> {
let a_bytes = a
.as_bytes()
.ok_or_else(|| rquickjs::Error::new_from_js("buffer", "Detached buffer"))?;
let b_bytes = b
.as_bytes()
.ok_or_else(|| rquickjs::Error::new_from_js("buffer", "Detached buffer"))?;
if a_bytes.len() != b_bytes.len() {
return Err(rquickjs::Error::new_from_js(
"buffer",
"Input buffers must have the same byte length",
));
}
let mut result = 0u8;
for (x, y) in a_bytes.iter().zip(b_bytes.iter()) {
result |= x ^ y;
}
Ok(result == 0)
},
),
)
}
fn register_pbkdf2_hostcall(global: &rquickjs::Object<'_>) -> rquickjs::Result<()> {
global.set(
"__pi_crypto_pbkdf2_native",
Func::from(
|password: rquickjs::TypedArray<'_, u8>,
salt: rquickjs::TypedArray<'_, u8>,
iterations: u32,
keylen: usize,
digest: String,
encoding: String|
-> rquickjs::Result<String> {
if iterations == 0 {
return Err(rquickjs::Error::new_from_js(
"number",
"pbkdf2 iterations must be positive",
));
}
if keylen == 0 {
return Err(rquickjs::Error::new_from_js(
"number",
"pbkdf2 keylen must be positive",
));
}
if keylen > KDF_MAX_OUTPUT_BYTES {
let msg =
format!("pbkdf2 keylen exceeds maximum ({KDF_MAX_OUTPUT_BYTES} bytes)");
return Err(rquickjs::Error::new_into_js_message(
"number", "pbkdf2", msg,
));
}
if iterations > KDF_MAX_PBKDF2_ITERATIONS {
let msg =
format!("pbkdf2 iterations exceeds maximum ({KDF_MAX_PBKDF2_ITERATIONS})");
return Err(rquickjs::Error::new_into_js_message(
"number", "pbkdf2", msg,
));
}
let password_bytes = password
.as_bytes()
.ok_or_else(|| rquickjs::Error::new_from_js("buffer", "Detached buffer"))?;
let salt_bytes = salt
.as_bytes()
.ok_or_else(|| rquickjs::Error::new_from_js("buffer", "Detached buffer"))?;
let mut out = vec![0u8; keylen];
let () = match digest.as_str() {
"sha256" => {
pbkdf2_hmac::<Sha256>(password_bytes, salt_bytes, iterations, &mut out);
}
"sha512" => {
pbkdf2_hmac::<sha2::Sha512>(
password_bytes,
salt_bytes,
iterations,
&mut out,
);
}
"sha1" => {
pbkdf2_hmac::<sha1::Sha1>(password_bytes, salt_bytes, iterations, &mut out);
}
"md5" => {
pbkdf2_hmac::<md5::Md5>(password_bytes, salt_bytes, iterations, &mut out);
}
_ => {
return Err(rquickjs::Error::new_from_js(
"string",
"unsupported pbkdf2 digest",
));
}
};
Ok(encode_output(&out, &encoding))
},
),
)
}
fn register_scrypt_hostcall(global: &rquickjs::Object<'_>) -> rquickjs::Result<()> {
global.set(
"__pi_crypto_scrypt_native",
Func::from(
|password: rquickjs::TypedArray<'_, u8>,
salt: rquickjs::TypedArray<'_, u8>,
keylen: usize,
log_n: u8,
r: u32,
p: u32,
encoding: String|
-> rquickjs::Result<String> {
if keylen == 0 {
return Err(rquickjs::Error::new_from_js(
"number",
"scrypt keylen must be positive",
));
}
if keylen > KDF_MAX_OUTPUT_BYTES {
let msg = format!(
"scrypt keylen exceeds maximum ({KDF_MAX_OUTPUT_BYTES} bytes)"
);
return Err(rquickjs::Error::new_into_js_message(
"number",
"scrypt",
msg,
));
}
if log_n > KDF_MAX_SCRYPT_LOG_N {
let msg = format!(
"scrypt N exceeds maximum (2^{KDF_MAX_SCRYPT_LOG_N})"
);
return Err(rquickjs::Error::new_into_js_message(
"number",
"scrypt",
msg,
));
}
if r == 0 || p == 0 {
return Err(rquickjs::Error::new_from_js(
"number",
"scrypt r/p must be positive",
));
}
if r > KDF_MAX_SCRYPT_R || p > KDF_MAX_SCRYPT_P {
let msg = format!(
"scrypt r/p exceeds maximum (r<= {KDF_MAX_SCRYPT_R}, p<= {KDF_MAX_SCRYPT_P})"
);
return Err(rquickjs::Error::new_into_js_message(
"number",
"scrypt",
msg,
));
}
let n = 1usize
.checked_shl(u32::from(log_n))
.ok_or_else(|| rquickjs::Error::new_from_js("number", "invalid scrypt N"))?;
let mem_bytes = 128usize
.checked_mul(r as usize)
.and_then(|value| value.checked_mul(n))
.and_then(|value| value.checked_mul(p as usize))
.ok_or_else(|| {
rquickjs::Error::new_from_js("number", "scrypt memory size overflow")
})?;
if mem_bytes > KDF_MAX_SCRYPT_MEM_BYTES {
let msg = format!(
"scrypt parameters exceed memory limit ({KDF_MAX_SCRYPT_MEM_BYTES} bytes)"
);
return Err(rquickjs::Error::new_into_js_message(
"number",
"scrypt",
msg,
));
}
let password_bytes = password
.as_bytes()
.ok_or_else(|| rquickjs::Error::new_from_js("buffer", "Detached buffer"))?;
let salt_bytes = salt
.as_bytes()
.ok_or_else(|| rquickjs::Error::new_from_js("buffer", "Detached buffer"))?;
let params = ScryptParams::new(log_n, r, p, keylen).map_err(|_| {
rquickjs::Error::new_from_js("number", "invalid scrypt params")
})?;
let mut out = vec![0u8; keylen];
scrypt(password_bytes, salt_bytes, ¶ms, &mut out).map_err(|_| {
rquickjs::Error::new_from_js("crypto", "scrypt derivation failed")
})?;
Ok(encode_output(&out, &encoding))
},
),
)
}
fn encode_output(bytes: &[u8], encoding: &str) -> String {
match encoding {
"base64" => {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
_ => hex_lower(bytes),
}
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: [char; 16] = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
];
let mut output = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
output.push(HEX[usize::from(byte >> 4)]);
output.push(HEX[usize::from(byte & 0x0f)]);
}
output
}
fn hex_decode(hex: &str) -> Vec<u8> {
let mut bytes = Vec::with_capacity(hex.len() / 2);
let mut chars = hex.chars();
while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
if let (Some(h), Some(l)) = (hi.to_digit(16), lo.to_digit(16)) {
if let Ok(byte) = u8::try_from(h * 16 + l) {
bytes.push(byte);
}
}
}
bytes
}
fn map_entropy_error(api: &'static str, err: getrandom::Error) -> rquickjs::Error {
tracing::error!(
event = "pijs.crypto.entropy_failure",
api,
error = %err,
"OS randomness unavailable"
);
rquickjs::Error::new_into_js_message("crypto", api, format!("OS randomness unavailable: {err}"))
}
fn fill_random_bytes_with<F, E>(len: usize, mut fill: F) -> Result<Vec<u8>, E>
where
F: FnMut(&mut [u8]) -> Result<(), E>,
{
let mut out = vec![0u8; len];
if len > 0 {
fill(&mut out)?;
}
Ok(out)
}
fn random_bytes(len: usize) -> Result<Vec<u8>, getrandom::Error> {
fill_random_bytes_with(len, getrandom::fill)
}
fn random_uuid_with<F, E>(mut fill: F) -> Result<String, E>
where
F: FnMut(&mut [u8]) -> Result<(), E>,
{
let mut bytes = [0_u8; 16];
fill(&mut bytes)?;
Ok(Builder::from_random_bytes(bytes).into_uuid().to_string())
}
fn random_uuid() -> Result<String, getrandom::Error> {
random_uuid_with(getrandom::fill)
}
pub const NODE_CRYPTO_JS: &str = r"
// Helper: convert hex string to Uint8Array with Buffer-like toString
function hexToBuffer(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
bytes.toString = function(enc) {
if (enc === 'hex') return hex;
if (enc === 'base64') {
let binary = '';
let chunk = [];
for (let i = 0; i < this.length; i++) {
chunk.push(this[i]);
if (chunk.length >= 4096) {
binary += String.fromCharCode.apply(null, chunk);
chunk.length = 0;
}
}
if (chunk.length > 0) {
binary += String.fromCharCode.apply(null, chunk);
}
return globalThis.btoa(binary);
}
return new TextDecoder().decode(this);
};
return bytes;
}
// Helper: Uint8Array to hex string
function bufToHex(buf) {
return Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join('');
}
function requireCryptoHostcall(hostcallName, apiName) {
const hostcall = globalThis[hostcallName];
if (typeof hostcall !== 'function') {
throw new Error(`${apiName} not available: crypto hostcalls not registered`);
}
return hostcall;
}
function combineChunks(chunks) {
const totalLen = chunks.reduce((acc, c) => acc + c.length, 0);
const combined = new Uint8Array(totalLen);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return combined;
}
function toUint8Array(input) {
if (input instanceof Uint8Array) return input;
if (typeof input === 'string') return new TextEncoder().encode(input);
return new TextEncoder().encode(String(input ?? ''));
}
function normalizeDigestName(input) {
if (input === undefined || input === null) return 'sha1';
return String(input)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
}
function unsupportedCryptoApi(name) {
throw new Error(`${name} is not implemented in the Pi node:crypto shim`);
}
export function randomUUID() {
const randomUuidNative = requireCryptoHostcall(
'__pi_crypto_random_uuid_native',
'randomUUID',
);
return randomUuidNative();
}
export function createHash(algorithm) {
if (algorithm === undefined || algorithm === null || String(algorithm).trim() === '') {
throw new Error('createHash: algorithm is required');
}
const algo = normalizeDigestName(algorithm);
const chunks = [];
let finalized = false;
return {
update(input) {
if (finalized) {
throw new Error('Hash.digest() already called');
}
chunks.push(toUint8Array(input));
return this;
},
digest(encoding) {
if (finalized) {
throw new Error('Hash.digest() already called');
}
finalized = true;
const hashNative = requireCryptoHostcall('__pi_crypto_hash_native', 'createHash');
const data = combineChunks(chunks);
const hex = hashNative(algo, data, 'hex');
if (!encoding) return hexToBuffer(hex);
if (encoding === 'hex') return hex;
if (encoding === 'base64') {
const buf = hexToBuffer(hex);
return globalThis.btoa(String.fromCharCode(...buf));
}
throw new Error(`createHash.digest: unsupported encoding '${encoding}'`);
},
};
}
export function createHmac(algorithm, key) {
if (algorithm === undefined || algorithm === null || String(algorithm).trim() === '') {
throw new Error('createHmac: algorithm is required');
}
const algo = normalizeDigestName(algorithm);
const chunks = [];
const keyBuf = toUint8Array(key);
let finalized = false;
return {
update(input) {
if (finalized) {
throw new Error('Hmac.digest() already called');
}
chunks.push(toUint8Array(input));
return this;
},
digest(encoding) {
if (finalized) {
throw new Error('Hmac.digest() already called');
}
finalized = true;
const hmacNative = requireCryptoHostcall('__pi_crypto_hmac_native', 'createHmac');
const data = combineChunks(chunks);
const hex = hmacNative(algo, keyBuf, data, 'hex');
if (!encoding) return hexToBuffer(hex);
if (encoding === 'hex') return hex;
if (encoding === 'base64') {
const buf = hexToBuffer(hex);
return globalThis.btoa(String.fromCharCode(...buf));
}
throw new Error(`createHmac.digest: unsupported encoding '${encoding}'`);
},
};
}
export function randomBytes(size) {
if (!Number.isSafeInteger(size) || size < 0) {
throw new Error('randomBytes: size must be a non-negative integer');
}
const randomBytesNative = requireCryptoHostcall(
'__pi_crypto_random_bytes_native',
'randomBytes',
);
return hexToBuffer(randomBytesNative(size));
}
export function randomInt(min, max) {
if (max === undefined) { max = min; min = 0; }
if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) {
throw new Error('randomInt: min/max must be safe integers');
}
if (min >= max) {
throw new Error('randomInt: min must be less than max');
}
const randomIntNative = requireCryptoHostcall(
'__pi_crypto_random_int_native',
'randomInt',
);
return randomIntNative(min, max);
}
export function timingSafeEqual(a, b) {
if (typeof globalThis.__pi_crypto_timing_safe_equal_native === 'function') {
return globalThis.__pi_crypto_timing_safe_equal_native(a, b);
}
if (a.length !== b.length) throw new Error('Input buffers must have the same byte length');
let result = 0;
for (let i = 0; i < a.length; i++) result |= a[i] ^ b[i];
return result === 0;
}
export function getHashes() {
return ['md5', 'sha1', 'sha256', 'sha512'];
}
export function pbkdf2Sync(password, salt, iterations, keylen, digest) {
const algo = normalizeDigestName(digest);
if (!Number.isSafeInteger(iterations) || iterations <= 0) {
throw new Error('pbkdf2Sync: iterations must be a positive integer');
}
if (!Number.isSafeInteger(keylen) || keylen <= 0) {
throw new Error('pbkdf2Sync: keylen must be a positive integer');
}
if (iterations > 1000000) {
throw new Error('pbkdf2Sync: iterations must be <= 1000000');
}
if (keylen > 1048576) {
throw new Error('pbkdf2Sync: keylen must be <= 1048576');
}
const pbkdf2Native = requireCryptoHostcall(
'__pi_crypto_pbkdf2_native',
'pbkdf2Sync',
);
const hex = pbkdf2Native(
toUint8Array(password),
toUint8Array(salt),
iterations,
keylen,
algo,
'hex',
);
return hexToBuffer(hex);
}
export function pbkdf2(password, salt, iterations, keylen, digest, callback) {
if (typeof digest === 'function') {
callback = digest;
digest = undefined;
}
if (typeof callback !== 'function') {
throw new Error('pbkdf2: callback is required');
}
try {
const value = pbkdf2Sync(password, salt, iterations, keylen, digest);
callback(null, value);
} catch (e) {
callback(e);
}
}
export function createCipheriv(algorithm, key, iv) {
unsupportedCryptoApi('createCipheriv');
}
export function createDecipheriv(algorithm, key, iv) {
unsupportedCryptoApi('createDecipheriv');
}
export function scryptSync(password, salt, keylen, options) {
if (!Number.isSafeInteger(keylen) || keylen <= 0) {
throw new Error('scryptSync: keylen must be a positive integer');
}
if (keylen > 1048576) {
throw new Error('scryptSync: keylen must be <= 1048576');
}
let encoding;
let opts = {};
if (typeof options === 'string') {
encoding = options;
} else if (options && typeof options === 'object') {
opts = options;
if (typeof options.encoding === 'string') {
encoding = options.encoding;
}
}
const nRaw = Number.isSafeInteger(opts.N)
? opts.N
: (Number.isSafeInteger(opts.cost) ? opts.cost : 16384);
const r = Number.isSafeInteger(opts.r) ? opts.r : 8;
const p = Number.isSafeInteger(opts.p) ? opts.p : 1;
if (r <= 0 || p <= 0) {
throw new Error('scryptSync: r/p must be positive integers');
}
if (!Number.isSafeInteger(nRaw) || nRaw <= 1) {
throw new Error('scryptSync: N must be an integer > 1');
}
const logN = Math.log2(nRaw);
if (!Number.isFinite(logN) || Math.floor(logN) !== logN) {
throw new Error('scryptSync: N must be a power of two');
}
if (logN > 20) {
throw new Error('scryptSync: N must be <= 2^20');
}
if (r > 16 || p > 16) {
throw new Error('scryptSync: r/p must be <= 16');
}
const maxMem = 32 * 1024 * 1024;
const n = 1 << logN;
const memBytes = 128 * r * n * p;
if (memBytes > maxMem) {
throw new Error(`scryptSync: parameters exceed memory limit (${maxMem} bytes)`);
}
const scryptNative = requireCryptoHostcall(
'__pi_crypto_scrypt_native',
'scryptSync',
);
const hex = scryptNative(
toUint8Array(password),
toUint8Array(salt),
keylen,
logN,
r,
p,
'hex',
);
const buffer = hexToBuffer(hex);
return encoding ? buffer.toString(encoding) : buffer;
}
export function scrypt(password, salt, keylen, options, callback) {
if (typeof options === 'function') { callback = options; }
if (typeof callback !== 'function') {
throw new Error('scrypt: callback is required');
}
try {
const value = scryptSync(password, salt, keylen, options);
callback(null, value);
} catch (e) {
callback(e);
}
}
export function generateKeyPairSync() { unsupportedCryptoApi('generateKeyPairSync'); }
export function publicEncrypt() { unsupportedCryptoApi('publicEncrypt'); }
export function privateDecrypt() { unsupportedCryptoApi('privateDecrypt'); }
export function sign() { unsupportedCryptoApi('sign'); }
export function verify() { unsupportedCryptoApi('verify'); }
export default {
randomUUID, createHash, createHmac, randomBytes,
randomInt, timingSafeEqual, getHashes, pbkdf2Sync, pbkdf2,
createCipheriv, createDecipheriv, scryptSync, scrypt,
generateKeyPairSync, publicEncrypt, privateDecrypt, sign, verify,
};
";
#[cfg(test)]
mod tests {
use super::*;
use sha2::Digest;
#[test]
fn hex_lower_empty() {
assert_eq!(hex_lower(&[]), "");
}
#[test]
fn hex_lower_single_byte() {
assert_eq!(hex_lower(&[0x00]), "00");
assert_eq!(hex_lower(&[0xff]), "ff");
assert_eq!(hex_lower(&[0xab]), "ab");
}
#[test]
fn hex_lower_known_bytes() {
assert_eq!(hex_lower(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
}
#[test]
fn hex_lower_all_digits() {
assert_eq!(
hex_lower(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]),
"0123456789abcdef"
);
}
#[test]
fn hex_decode_empty() {
assert_eq!(hex_decode(""), Vec::<u8>::new());
}
#[test]
fn hex_decode_valid() {
assert_eq!(hex_decode("deadbeef"), vec![0xde, 0xad, 0xbe, 0xef]);
}
#[test]
fn hex_decode_uppercase() {
assert_eq!(hex_decode("DEADBEEF"), vec![0xde, 0xad, 0xbe, 0xef]);
}
#[test]
fn hex_decode_odd_length_drops_trailing() {
assert_eq!(hex_decode("abc"), vec![0xab]);
}
#[test]
fn hex_decode_invalid_chars_skipped() {
assert_eq!(hex_decode("ffggaa"), vec![0xff, 0xaa]);
}
#[test]
fn hex_decode_roundtrip() {
let original = vec![0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef];
let encoded = hex_lower(&original);
assert_eq!(hex_decode(&encoded), original);
}
#[test]
fn encode_output_hex() {
let bytes = [0xde, 0xad, 0xbe, 0xef];
assert_eq!(encode_output(&bytes, "hex"), "deadbeef");
}
#[test]
fn encode_output_base64() {
let bytes = b"hello";
assert_eq!(encode_output(bytes, "base64"), "aGVsbG8=");
}
#[test]
fn encode_output_unknown_falls_back_to_hex() {
let bytes = [0xff];
assert_eq!(encode_output(&bytes, "unknown"), "ff");
}
#[test]
fn random_bytes_correct_length() {
for len in [0, 1, 4, 16, 32, 64, 100] {
let bytes = random_bytes(len).expect("random bytes");
assert_eq!(
bytes.len(),
len,
"random_bytes({len}) should return {len} bytes"
);
}
}
#[test]
fn random_bytes_two_calls_differ() {
let a = random_bytes(32).expect("first random bytes");
let b = random_bytes(32).expect("second random bytes");
assert_ne!(a, b, "two random_bytes(32) calls should differ");
}
#[test]
fn random_bytes_propagates_fill_errors() {
let err = fill_random_bytes_with(8, |_| Err("entropy unavailable")).unwrap_err();
assert_eq!(err, "entropy unavailable");
}
#[test]
fn sha256_hello() {
let mut h = Sha256::new();
h.update(b"hello");
let result = hex_lower(&h.finalize());
assert_eq!(
result,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn sha256_empty() {
let mut h = Sha256::new();
h.update(b"");
let result = hex_lower(&h.finalize());
assert_eq!(
result,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn sha512_hello() {
let mut h = sha2::Sha512::new();
h.update(b"hello");
let result = hex_lower(&h.finalize());
assert_eq!(
result,
"9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043"
);
}
#[test]
fn sha1_hello() {
let mut h = sha1::Sha1::new();
h.update(b"hello");
let result = hex_lower(&h.finalize());
assert_eq!(result, "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d");
}
#[test]
fn md5_hello() {
let mut h = md5::Md5::new();
h.update(b"hello");
let result = hex_lower(&h.finalize());
assert_eq!(result, "5d41402abc4b2a76b9719d911017c592");
}
#[test]
fn hmac_sha256_secret_hello() {
use hmac::Mac;
let mut mac =
hmac::Hmac::<Sha256>::new_from_slice(b"secret").expect("create HMAC with test key");
mac.update(b"hello");
let result = hex_lower(&mac.finalize().into_bytes());
assert_eq!(
result,
"88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b"
);
}
#[test]
fn hmac_sha1_key_data() {
use hmac::Mac;
let mut mac =
hmac::Hmac::<sha1::Sha1>::new_from_slice(b"key").expect("create HMAC with test key");
mac.update(b"data");
let result = hex_lower(&mac.finalize().into_bytes());
assert_eq!(result, "104152c5bfdca07bc633eebd46199f0255c9f49d");
}
#[test]
fn uuid_v4_format() {
let id = random_uuid().expect("random uuid");
let re = regex::Regex::new(
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
)
.expect("compile UUID v4 regex pattern");
assert!(re.is_match(&id), "UUID should be v4 format: {id}");
}
#[test]
fn uuid_v4_uniqueness() {
let a = random_uuid().expect("first random uuid");
let b = random_uuid().expect("second random uuid");
assert_ne!(a, b);
}
#[test]
fn random_uuid_propagates_fill_errors() {
let err = random_uuid_with(|_| Err("entropy unavailable")).unwrap_err();
assert_eq!(err, "entropy unavailable");
}
#[test]
fn timing_safe_equal_same_bytes() {
let a = hex_decode("01020304");
let b = hex_decode("01020304");
let mut result = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
result |= x ^ y;
}
assert_eq!(result, 0);
}
#[test]
fn timing_safe_different_bytes() {
let a = hex_decode("01020304");
let b = hex_decode("01020305");
let mut result = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
result |= x ^ y;
}
assert_ne!(result, 0);
}
#[test]
fn encode_sha256_hello_base64() {
let mut h = Sha256::new();
h.update(b"hello");
let result = encode_output(&h.finalize(), "base64");
assert_eq!(result, "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=");
}
#[test]
fn random_bytes_hostcall_roundtrip() {
for len in [0, 1, 8, 16, 32] {
let hex = hex_lower(&random_bytes(len).expect("random bytes"));
assert_eq!(hex.len(), len * 2, "hex should be 2x the byte length");
let decoded = hex_decode(&hex);
assert_eq!(decoded.len(), len, "decoded length should match original");
}
}
#[test]
fn random_int_validates_finite_inputs() {
use rquickjs::{Context, Runtime};
let runtime = Runtime::new().expect("create runtime");
let context = Context::full(&runtime).expect("create context");
context
.with(|ctx| -> rquickjs::Result<()> {
let global = ctx.globals();
register_random_int_hostcall(&global)?;
let hostcall: rquickjs::Function = global.get("__pi_crypto_random_int_native")?;
assert!(hostcall.call::<_, f64>((f64::NAN, 10.0)).is_err());
assert!(hostcall.call::<_, f64>((0.0, f64::NAN)).is_err());
assert!(hostcall.call::<_, f64>((f64::INFINITY, 10.0)).is_err());
assert!(hostcall.call::<_, f64>((0.0, f64::INFINITY)).is_err());
assert!(hostcall.call::<_, f64>((f64::NEG_INFINITY, 10.0)).is_err());
let result = hostcall.call::<_, f64>((0.0, 100.0))?;
assert!((0.0..100.0).contains(&result));
Ok(())
})
.expect("test NaN/Inf validation");
}
#[test]
fn node_crypto_js_has_content() {
assert!(!NODE_CRYPTO_JS.is_empty());
assert!(NODE_CRYPTO_JS.contains("createHash"));
assert!(NODE_CRYPTO_JS.contains("createHmac"));
assert!(NODE_CRYPTO_JS.contains("randomUUID"));
assert!(NODE_CRYPTO_JS.contains("randomBytes"));
assert!(NODE_CRYPTO_JS.contains("timingSafeEqual"));
assert!(NODE_CRYPTO_JS.contains("getHashes"));
}
mod proptest_crypto_shim {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn hex_lower_roundtrips_through_hex_decode(bytes in prop::collection::vec(any::<u8>(), 0..128)) {
let encoded = hex_lower(&bytes);
let decoded = hex_decode(&encoded);
assert_eq!(
decoded, bytes,
"hex_lower → hex_decode should roundtrip"
);
}
#[test]
fn hex_lower_output_length_is_double_input(bytes in prop::collection::vec(any::<u8>(), 0..128)) {
let encoded = hex_lower(&bytes);
assert_eq!(
encoded.len(), bytes.len() * 2,
"hex output should be exactly 2x input length"
);
}
#[test]
fn hex_lower_output_is_lowercase_hex(bytes in prop::collection::vec(any::<u8>(), 0..64)) {
let encoded = hex_lower(&bytes);
assert!(
encoded.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"hex_lower output should only contain lowercase hex chars: {encoded}"
);
}
#[test]
fn hex_decode_odd_length_drops_trailing(
bytes in prop::collection::vec(any::<u8>(), 1..64),
extra_char in prop::sample::select(vec!['0', '5', 'a', 'f']),
) {
let mut hex = hex_lower(&bytes);
hex.push(extra_char);
let decoded = hex_decode(&hex);
assert_eq!(
decoded, bytes,
"odd hex should decode the even prefix correctly"
);
}
#[test]
fn encode_output_hex_matches_hex_lower(bytes in prop::collection::vec(any::<u8>(), 0..64)) {
let via_encode = encode_output(&bytes, "hex");
let via_hex_lower = hex_lower(&bytes);
assert_eq!(
via_encode, via_hex_lower,
"encode_output(hex) should match hex_lower"
);
}
#[test]
fn encode_output_unknown_encoding_falls_back_to_hex(
bytes in prop::collection::vec(any::<u8>(), 0..32),
encoding in "[a-z]{3,8}".prop_filter(
"must not be known encoding",
|e| e != "hex" && e != "base64",
),
) {
let result = encode_output(&bytes, &encoding);
let expected = hex_lower(&bytes);
assert_eq!(
result, expected,
"unknown encoding '{encoding}' should fall back to hex"
);
}
#[test]
fn random_bytes_returns_correct_length(len in 0..256usize) {
let bytes = random_bytes(len).expect("random bytes");
assert_eq!(
bytes.len(), len,
"random_bytes({len}) should return {len} bytes"
);
}
#[test]
fn sha256_hash_is_always_32_bytes(data in prop::collection::vec(any::<u8>(), 0..200)) {
let mut h = Sha256::new();
h.update(&data);
let result = h.finalize();
assert_eq!(
result.len(), 32,
"SHA-256 should always produce 32 bytes"
);
}
#[test]
fn sha256_is_deterministic(data in prop::collection::vec(any::<u8>(), 0..200)) {
let mut h1 = Sha256::new();
h1.update(&data);
let r1 = hex_lower(&h1.finalize());
let mut h2 = Sha256::new();
h2.update(&data);
let r2 = hex_lower(&h2.finalize());
assert_eq!(r1, r2, "SHA-256 must be deterministic");
}
#[test]
fn timing_safe_equal_is_reflexive(bytes in prop::collection::vec(any::<u8>(), 0..64)) {
let mut result = 0u8;
for (x, y) in bytes.iter().zip(bytes.iter()) {
result |= x ^ y;
}
assert_eq!(result, 0, "byte slice compared to itself should be equal");
}
#[test]
fn timing_safe_unequal_detects_single_bit_flip(
bytes in prop::collection::vec(any::<u8>(), 1..64),
flip_idx in any::<prop::sample::Index>(),
flip_bit in 0..8u8,
) {
let idx = flip_idx.index(bytes.len());
let mut other = bytes.clone();
other[idx] ^= 1 << flip_bit;
if other == bytes {
return Ok(());
}
let mut result = 0u8;
for (x, y) in bytes.iter().zip(other.iter()) {
result |= x ^ y;
}
assert_ne!(result, 0, "flipped byte should be detected as unequal");
}
}
}
}