From 5cc4c5e224d9c9c18ef5c3a4f37f23b7d023bfa1 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Fri, 31 Jul 2026 05:40:43 +0000 Subject: [PATCH 1/2] add hand-written blake2b and argon2id primitives to the runtime blake2b follows rfc 7693 (keyed and unkeyed, digests up to 64 bytes) and is pinned by the rfc appendix vector plus official kat values. argon2id follows rfc 9106; the core supports all three variants so the rfc's argon2d, argon2i, and argon2id vectors can each pin the indexing logic, but only argon2id is meant for use. the memory cost is capped at 1 GiB and allocation failures return an error instead of aborting. --- cranelift/runtime/src/argon2.rs | 594 +++++++++++++++++++++++++++++++ cranelift/runtime/src/blake2b.rs | 344 ++++++++++++++++++ cranelift/runtime/src/lib.rs | 2 + 3 files changed, 940 insertions(+) create mode 100644 cranelift/runtime/src/argon2.rs create mode 100644 cranelift/runtime/src/blake2b.rs diff --git a/cranelift/runtime/src/argon2.rs b/cranelift/runtime/src/argon2.rs new file mode 100644 index 00000000..6c9f4147 --- /dev/null +++ b/cranelift/runtime/src/argon2.rs @@ -0,0 +1,594 @@ +//! Argon2 (RFC 9106) memory-hard password hashing. +//! +//! Hand-written on top of the BLAKE2b module for the same reason BLAKE2b is: +//! the runtime keeps its dependency set small, and `ring` offers neither +//! primitive. Only Argon2id is exposed to pith — it is the variant RFC 9106 +//! recommends for password hashing — but the core supports all three +//! variants so the official test vectors for each can pin down the indexing +//! logic, which is where a subtly wrong implementation would still produce +//! plausible-looking output. +//! +//! The memory matrix is filled single-threaded. The `lanes` parameter still +//! changes the output as the RFC requires; it just doesn't fan out across +//! threads. For the parameter sizes a password hash uses, correctness and +//! simplicity beat the modest wall-clock win. + +use crate::blake2b; + +/// Ceiling on the memory cost, in KiB (1 GiB). The memory parameter is an +/// allocation request from whatever config supplied it, so the runtime bounds +/// it and fails cleanly instead of letting a bad value take down the process. +pub const MAX_MEMORY_KIB: u32 = 1 << 20; + +/// Smallest salt accepted, per RFC 9106's recommendation for password hashing. +pub const MIN_SALT_LEN: usize = 8; + +/// Largest salt accepted. +pub const MAX_SALT_LEN: usize = 1024; + +/// Smallest tag (output) length, from RFC 9106. +pub const MIN_TAG_LEN: usize = 4; + +/// Largest tag (output) length accepted. +pub const MAX_TAG_LEN: usize = 1024; + +/// Ceiling on the pass count (RFC 9106 allows up to 2^32-1; nothing sane +/// needs more than this). +pub const MAX_PASSES: u32 = 1024; + +/// Ceiling on parallelism. +pub const MAX_LANES: u32 = 64; + +/// A memory block: 1024 bytes as 128 little-endian words. +const BLOCK_WORDS: usize = 128; +type Block = [u64; BLOCK_WORDS]; +const ZERO_BLOCK: Block = [0u64; BLOCK_WORDS]; + +/// Each lane is filled in four slices, with a synchronisation point between +/// slices (RFC 9106 section 3.4). +const SYNC_POINTS: usize = 4; + +const VERSION: u64 = 0x13; + +// argon2d and argon2i are never built outside of tests: they exist so the +// official vectors can pin the indexing logic on every addressing path, +// while only argon2id is exposed for real use +#[allow(dead_code)] +#[derive(Clone, Copy, PartialEq)] +enum Variant { + Argon2d = 0, + Argon2i = 1, + Argon2id = 2, +} + +/// Compute an Argon2id tag. `passes` is the time cost, `memory_kib` the +/// memory cost in KiB, `lanes` the parallelism degree. `secret` and `ad` are +/// the optional keyed-secret and associated-data inputs; pass empty slices +/// when unused. +#[allow(clippy::too_many_arguments)] +pub fn argon2id( + password: &[u8], + salt: &[u8], + secret: &[u8], + ad: &[u8], + passes: u32, + memory_kib: u32, + lanes: u32, + tag_len: usize, +) -> Result, &'static str> { + argon2( + Variant::Argon2id, + password, + salt, + secret, + ad, + passes, + memory_kib, + lanes, + tag_len, + ) +} + +#[allow(clippy::too_many_arguments)] +fn argon2( + variant: Variant, + password: &[u8], + salt: &[u8], + secret: &[u8], + ad: &[u8], + passes: u32, + memory_kib: u32, + lanes: u32, + tag_len: usize, +) -> Result, &'static str> { + if !(MIN_TAG_LEN..=MAX_TAG_LEN).contains(&tag_len) { + return Err("argon2 tag length out of range"); + } + if !(MIN_SALT_LEN..=MAX_SALT_LEN).contains(&salt.len()) { + return Err("argon2 salt length out of range"); + } + if passes == 0 || passes > MAX_PASSES { + return Err("argon2 pass count out of range"); + } + if lanes == 0 || lanes > MAX_LANES { + return Err("argon2 parallelism out of range"); + } + if memory_kib > MAX_MEMORY_KIB { + return Err("argon2 memory cost above the runtime cap"); + } + if memory_kib < 8 * lanes { + return Err("argon2 memory cost below 8 blocks per lane"); + } + + let lanes = lanes as usize; + // round the block count down to a multiple of 4 * lanes (RFC section 3.2: + // m' = 4 * p * floor(m / 4p)) + let segment_len = memory_kib as usize / (SYNC_POINTS * lanes); + let lane_len = segment_len * SYNC_POINTS; + let block_count = lane_len * lanes; + + // this is the caller-sized allocation; fail cleanly instead of aborting + // on out-of-memory + let mut memory: Vec = Vec::new(); + if memory.try_reserve_exact(block_count).is_err() { + return Err("argon2 memory allocation failed"); + } + memory.resize(block_count, ZERO_BLOCK); + + let h0 = initial_hash( + variant, password, salt, secret, ad, passes, memory_kib, lanes, tag_len, + )?; + + let mut instance = Instance { + memory, + lanes, + lane_len, + segment_len, + passes, + variant, + }; + instance.fill_first_blocks(&h0)?; + for pass in 0..passes as usize { + for slice in 0..SYNC_POINTS { + for lane in 0..lanes { + instance.fill_segment(pass, lane, slice); + } + } + } + let tag = instance.extract_tag(tag_len); + // best-effort scrubbing of password-derived state + instance.memory.fill(ZERO_BLOCK); + tag +} + +/// The initial 64-byte digest H_0 (RFC 9106 section 3.2). +#[allow(clippy::too_many_arguments)] +fn initial_hash( + variant: Variant, + password: &[u8], + salt: &[u8], + secret: &[u8], + ad: &[u8], + passes: u32, + memory_kib: u32, + lanes: usize, + tag_len: usize, +) -> Result<[u8; 64], &'static str> { + if password.len() > u32::MAX as usize + || secret.len() > u32::MAX as usize + || ad.len() > u32::MAX as usize + { + return Err("argon2 input too long"); + } + let mut input = Vec::with_capacity(40 + password.len() + salt.len() + secret.len() + ad.len()); + for fixed in [ + lanes as u32, + tag_len as u32, + memory_kib, + passes, + VERSION as u32, + variant as u32, + ] { + input.extend_from_slice(&fixed.to_le_bytes()); + } + for var in [password, salt, secret, ad] { + input.extend_from_slice(&(var.len() as u32).to_le_bytes()); + input.extend_from_slice(var); + } + let Some(digest) = blake2b::hash(64, b"", &input) else { + return Err("argon2 initial hash failed"); + }; + input.fill(0); + let mut h0 = [0u8; 64]; + h0.copy_from_slice(&digest); + Ok(h0) +} + +/// The variable-length hash H' (RFC 9106 section 3.3): plain BLAKE2b up to +/// 64 bytes, and a 32-byte chain of BLAKE2b digests beyond that. +fn h_prime(out: &mut [u8], input: &[u8]) -> Result<(), &'static str> { + let total = out.len(); + let mut prefixed = Vec::with_capacity(4 + input.len()); + prefixed.extend_from_slice(&(total as u32).to_le_bytes()); + prefixed.extend_from_slice(input); + if total <= 64 { + let Some(digest) = blake2b::hash(total, b"", &prefixed) else { + return Err("argon2 h' failed"); + }; + out.copy_from_slice(&digest); + return Ok(()); + } + let Some(mut v) = blake2b::hash(64, b"", &prefixed) else { + return Err("argon2 h' failed"); + }; + let mut pos = 0; + let mut remaining = total; + while remaining > 64 { + out[pos..pos + 32].copy_from_slice(&v[..32]); + pos += 32; + remaining -= 32; + let next_len = remaining.min(64); + let Some(next) = blake2b::hash(next_len, b"", &v) else { + return Err("argon2 h' failed"); + }; + v = next; + } + out[pos..].copy_from_slice(&v); + Ok(()) +} + +struct Instance { + memory: Vec, + lanes: usize, + lane_len: usize, + segment_len: usize, + passes: u32, + variant: Variant, +} + +impl Instance { + /// B[lane][0] and B[lane][1] seed each lane from H_0 (RFC section 3.2). + fn fill_first_blocks(&mut self, h0: &[u8; 64]) -> Result<(), &'static str> { + for lane in 0..self.lanes { + for col in 0..2usize { + let mut seed = [0u8; 72]; + seed[..64].copy_from_slice(h0); + seed[64..68].copy_from_slice(&(col as u32).to_le_bytes()); + seed[68..72].copy_from_slice(&(lane as u32).to_le_bytes()); + let mut block_bytes = [0u8; 1024]; + h_prime(&mut block_bytes, &seed)?; + self.memory[lane * self.lane_len + col] = block_from_le_bytes(&block_bytes); + } + } + Ok(()) + } + + /// Fill one segment of one lane (RFC section 3.4). Argon2id switches from + /// data-independent to data-dependent addressing after the first two + /// slices of the first pass. + fn fill_segment(&mut self, pass: usize, lane: usize, slice: usize) { + let data_independent = match self.variant { + Variant::Argon2d => false, + Variant::Argon2i => true, + Variant::Argon2id => pass == 0 && slice < 2, + }; + + // data-independent addressing draws J_1 || J_2 from a pseudo-random + // block regenerated every 128 references + let mut address_block = ZERO_BLOCK; + let mut input_block = ZERO_BLOCK; + if data_independent { + input_block[0] = pass as u64; + input_block[1] = lane as u64; + input_block[2] = slice as u64; + input_block[3] = self.memory.len() as u64; + input_block[4] = self.passes as u64; + input_block[5] = self.variant as u64; + // input_block[6] is the counter; next_addresses bumps it + } + + // the first two blocks of every lane are the seed blocks + let mut starting_index = 0; + if pass == 0 && slice == 0 { + starting_index = 2; + if data_independent { + next_addresses(&mut address_block, &mut input_block); + } + } + + for index in starting_index..self.segment_len { + let col = slice * self.segment_len + index; + let prev_col = if col == 0 { self.lane_len - 1 } else { col - 1 }; + let prev_idx = lane * self.lane_len + prev_col; + + let pseudo_rand = if data_independent { + if index % BLOCK_WORDS == 0 { + next_addresses(&mut address_block, &mut input_block); + } + address_block[index % BLOCK_WORDS] + } else { + self.memory[prev_idx][0] + }; + + let ref_lane = if pass == 0 && slice == 0 { + lane + } else { + ((pseudo_rand >> 32) as usize) % self.lanes + }; + let ref_index = self.index_alpha( + pass, + slice, + index, + pseudo_rand as u32, + ref_lane == lane, + ); + + let prev = self.memory[prev_idx]; + let reference = self.memory[ref_lane * self.lane_len + ref_index]; + let with_xor = pass > 0; + fill_block( + &prev, + &reference, + &mut self.memory[lane * self.lane_len + col], + with_xor, + ); + } + } + + /// Map J_1 to a reference block index (RFC section 3.4.1.2): compute the + /// size of the reachable window, then pick a position skewed towards the + /// window's recent end via the x^2 mapping. + fn index_alpha(&self, pass: usize, slice: usize, index: usize, j1: u32, same_lane: bool) -> usize { + let reference_area = if pass == 0 { + if slice == 0 { + // only this lane's earlier blocks in this segment (index is + // at least 2 here, so this never underflows) + index - 1 + } else if same_lane { + slice * self.segment_len + index - 1 + } else { + slice * self.segment_len - if index == 0 { 1 } else { 0 } + } + } else if same_lane { + self.lane_len - self.segment_len + index - 1 + } else { + self.lane_len - self.segment_len - if index == 0 { 1 } else { 0 } + }; + + let area = reference_area as u64; + let x = (j1 as u64 * j1 as u64) >> 32; + let relative = area - 1 - ((area * x) >> 32); + + // after the first pass the window starts just past the current slice + // and wraps around the lane + let start = if pass == 0 || slice == SYNC_POINTS - 1 { + 0 + } else { + (slice + 1) * self.segment_len + }; + (start + relative as usize) % self.lane_len + } + + /// XOR the last column together and hash it into the tag (RFC 3.2 step 7). + fn extract_tag(&self, tag_len: usize) -> Result, &'static str> { + let mut last = self.memory[self.lane_len - 1]; + for lane in 1..self.lanes { + let block = &self.memory[lane * self.lane_len + self.lane_len - 1]; + for (acc, word) in last.iter_mut().zip(block.iter()) { + *acc ^= word; + } + } + let mut block_bytes = [0u8; 1024]; + for (i, word) in last.iter().enumerate() { + block_bytes[i * 8..i * 8 + 8].copy_from_slice(&word.to_le_bytes()); + } + let mut tag = vec![0u8; tag_len]; + h_prime(&mut tag, &block_bytes)?; + Ok(tag) + } +} + +fn block_from_le_bytes(bytes: &[u8; 1024]) -> Block { + let mut block = ZERO_BLOCK; + for (i, word) in block.iter_mut().enumerate() { + let mut chunk = [0u8; 8]; + chunk.copy_from_slice(&bytes[i * 8..i * 8 + 8]); + *word = u64::from_le_bytes(chunk); + } + block +} + +/// Generate the next block of data-independent reference addresses: +/// addresses = G(zero, G(zero, input)) with the counter bumped first. +fn next_addresses(address: &mut Block, input: &mut Block) { + input[6] += 1; + let input_copy = *input; + fill_block(&ZERO_BLOCK, &input_copy, address, false); + let address_copy = *address; + fill_block(&ZERO_BLOCK, &address_copy, address, false); +} + +/// The compression function G (RFC section 3.5): XOR the inputs, run the +/// BLAKE2b-style permutation over rows then columns, and XOR the result back. +fn fill_block(prev: &Block, reference: &Block, next: &mut Block, with_xor: bool) { + let mut r = ZERO_BLOCK; + for (i, word) in r.iter_mut().enumerate() { + *word = prev[i] ^ reference[i]; + } + let mut z = r; + + // rows: 8 rows of 16 consecutive words + for row in 0..8 { + permute(&mut z, |i| row * 16 + i); + } + // columns: the block is an 8x8 grid of 16-byte cells; column `col` + // gathers the two words of each cell in that column + for col in 0..8 { + permute(&mut z, |i| (i / 2) * 16 + col * 2 + i % 2); + } + + for (i, word) in z.iter().enumerate() { + let value = r[i] ^ word; + if with_xor { + next[i] ^= value; + } else { + next[i] = value; + } + } +} + +/// The permutation P over 16 words selected by `idx`, mirroring a BLAKE2b +/// round with G_B replaced by the multiply-hardened G (RFC section 3.5). +fn permute(z: &mut Block, idx: impl Fn(usize) -> usize) { + let mut v = [0u64; 16]; + for (i, word) in v.iter_mut().enumerate() { + *word = z[idx(i)]; + } + for (a, b, c, d) in [ + (0, 4, 8, 12), + (1, 5, 9, 13), + (2, 6, 10, 14), + (3, 7, 11, 15), + (0, 5, 10, 15), + (1, 6, 11, 12), + (2, 7, 8, 13), + (3, 4, 9, 14), + ] { + quarter_round(&mut v, a, b, c, d); + } + for (i, word) in v.iter().enumerate() { + z[idx(i)] = *word; + } +} + +fn quarter_round(v: &mut [u64; 16], a: usize, b: usize, c: usize, d: usize) { + v[a] = mul_add(v[a], v[b]); + v[d] = (v[d] ^ v[a]).rotate_right(32); + v[c] = mul_add(v[c], v[d]); + v[b] = (v[b] ^ v[c]).rotate_right(24); + v[a] = mul_add(v[a], v[b]); + v[d] = (v[d] ^ v[a]).rotate_right(16); + v[c] = mul_add(v[c], v[d]); + v[b] = (v[b] ^ v[c]).rotate_right(63); +} + +/// a + b + 2 * lower32(a) * lower32(b), the multiplication that makes the +/// permutation compute-hard on 64-bit cores. +#[inline(always)] +fn mul_add(a: u64, b: u64) -> u64 { + let product = (a as u32 as u64).wrapping_mul(b as u32 as u64); + a.wrapping_add(b).wrapping_add(product.wrapping_add(product)) +} + +#[cfg(test)] +mod tests { + use super::{argon2, argon2id, Variant}; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() + } + + // the three test vectors from RFC 9106 section 5 share these inputs: + // m=32 KiB, t=3, p=4, 32-byte tag, and fixed password/salt/secret/ad + fn rfc_vector(variant: Variant) -> Result { + let password = [0x01u8; 32]; + let salt = [0x02u8; 16]; + let secret = [0x03u8; 8]; + let ad = [0x04u8; 12]; + argon2(variant, &password, &salt, &secret, &ad, 3, 32, 4, 32).map(|tag| hex(&tag)) + } + + #[test] + fn rfc_9106_argon2d_vector() { + assert_eq!( + rfc_vector(Variant::Argon2d), + Ok("512b391b6f1162975371d30919734294f868e3be3984f3c1a13a4db9fabe4acb".to_string()) + ); + } + + #[test] + fn rfc_9106_argon2i_vector() { + assert_eq!( + rfc_vector(Variant::Argon2i), + Ok("c814d9d1dc7f37aa13f0d77f2494bda1c8de6b016dd388d29952a4c4672b6ce8".to_string()) + ); + } + + #[test] + fn rfc_9106_argon2id_vector() { + assert_eq!( + rfc_vector(Variant::Argon2id), + Ok("0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659".to_string()) + ); + } + + #[test] + fn deterministic_and_salt_sensitive() { + let a = argon2id(b"password", b"somesalt", b"", b"", 1, 8, 1, 32); + let b = argon2id(b"password", b"somesalt", b"", b"", 1, 8, 1, 32); + let c = argon2id(b"password", b"othersalt", b"", b"", 1, 8, 1, 32); + assert!(a.is_ok()); + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn parameters_change_the_tag() { + let base = argon2id(b"password", b"somesalt", b"", b"", 2, 64, 1, 32); + assert_ne!(base, argon2id(b"password", b"somesalt", b"", b"", 3, 64, 1, 32)); + assert_ne!(base, argon2id(b"password", b"somesalt", b"", b"", 2, 128, 1, 32)); + assert_ne!(base, argon2id(b"password", b"somesalt", b"", b"", 2, 64, 2, 32)); + } + + #[test] + fn rejects_out_of_range_parameters() { + let salt = b"somesalt"; + // salt too short + assert!(argon2id(b"pw", b"short", b"", b"", 1, 8, 1, 32).is_err()); + // zero passes and zero lanes + assert!(argon2id(b"pw", salt, b"", b"", 0, 8, 1, 32).is_err()); + assert!(argon2id(b"pw", salt, b"", b"", 1, 8, 0, 32).is_err()); + // memory below 8 blocks per lane, and above the runtime cap + assert!(argon2id(b"pw", salt, b"", b"", 1, 7, 1, 32).is_err()); + assert!(argon2id(b"pw", salt, b"", b"", 1, 15, 2, 32).is_err()); + assert!(argon2id(b"pw", salt, b"", b"", 1, super::MAX_MEMORY_KIB + 1, 1, 32).is_err()); + // tag length outside 4..=1024 + assert!(argon2id(b"pw", salt, b"", b"", 1, 8, 1, 3).is_err()); + assert!(argon2id(b"pw", salt, b"", b"", 1, 8, 1, 1025).is_err()); + // the smallest legal configuration works + assert!(argon2id(b"pw", salt, b"", b"", 1, 8, 1, 4).is_ok()); + } + + #[test] + fn long_tags_use_the_hash_chain() { + // tags over 64 bytes exercise the iterative H' construction + let long = argon2id(b"password", b"somesalt", b"", b"", 1, 8, 1, 100); + assert!(matches!(&long, Ok(tag) if tag.len() == 100)); + let longer = argon2id(b"password", b"somesalt", b"", b"", 1, 8, 1, 128); + assert!(matches!(&longer, Ok(tag) if tag.len() == 128)); + // the shorter tag is not a prefix of the longer one + if let (Ok(long), Ok(longer)) = (long, longer) { + assert_ne!(long[..], longer[..100]); + } + } + + // not run by default: prints wall-clock timings for candidate password + // hashing defaults. run with: + // cargo test --release -p pith-runtime -- --ignored timing --nocapture + #[test] + #[ignore] + fn timing_for_candidate_defaults() { + let candidates = [ + ("owasp m=19 MiB t=2 p=1", 2u32, 19 * 1024, 1u32), + ("rfc second choice m=64 MiB t=3 p=4", 3, 64 * 1024, 4), + ("m=32 MiB t=3 p=1", 3, 32 * 1024, 1), + ]; + for (label, passes, memory_kib, lanes) in candidates { + let start = std::time::Instant::now(); + let result = argon2id(b"password", b"somesalt", b"", b"", passes, memory_kib, lanes, 32); + let elapsed = start.elapsed(); + assert!(result.is_ok()); + println!("{}: {:?}", label, elapsed); + } + } +} diff --git a/cranelift/runtime/src/blake2b.rs b/cranelift/runtime/src/blake2b.rs new file mode 100644 index 00000000..253ca47d --- /dev/null +++ b/cranelift/runtime/src/blake2b.rs @@ -0,0 +1,344 @@ +//! BLAKE2b (RFC 7693), keyed and unkeyed, with digests up to 64 bytes. +//! +//! Hand-written rather than pulled in as a crate: the runtime keeps its +//! dependency set small on purpose, and BLAKE2b is compact and fully +//! specified. Argon2 (see `argon2.rs`) builds on this module for both its +//! fixed-length hash H and its variable-length hash H'. + +/// Largest digest BLAKE2b can produce, in bytes. +pub const MAX_OUT_LEN: usize = 64; + +/// Largest key BLAKE2b accepts, in bytes. +pub const MAX_KEY_LEN: usize = 64; + +const BLOCK_LEN: usize = 128; + +/// Initialization vector from RFC 7693 section 2.6 (the same constants as +/// SHA-512's IV). +const IV: [u64; 8] = [ + 0x6a09e667f3bcc908, + 0xbb67ae8584caa73b, + 0x3c6ef372fe94f82b, + 0xa54ff53a5f1d36f1, + 0x510e527fade682d1, + 0x9b05688c2b3e6c1f, + 0x1f83d9abfb41bd6b, + 0x5be0cd19137e2179, +]; + +/// Message schedule from RFC 7693 section 2.7. BLAKE2b runs 12 rounds; rounds +/// 10 and 11 reuse the first two permutations. +const SIGMA: [[usize; 16]; 12] = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], + [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], + [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8], + [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], + [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9], + [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], + [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10], + [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], + [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0], + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], +]; + +/// Incremental BLAKE2b state. Feed input with [`Blake2b::update`] and close +/// with [`Blake2b::finalize`]. +pub struct Blake2b { + h: [u64; 8], + /// Total bytes compressed so far (the RFC's 128-bit counter; a u128 keeps + /// the carry into the high word automatic). + t: u128, + buf: [u8; BLOCK_LEN], + buf_len: usize, + out_len: usize, +} + +impl Blake2b { + /// Start a hash producing `out_len` bytes, keyed when `key` is non-empty. + /// Returns `None` when `out_len` is 0 or over 64, or the key is over 64 + /// bytes. + pub fn new(out_len: usize, key: &[u8]) -> Option { + if out_len == 0 || out_len > MAX_OUT_LEN || key.len() > MAX_KEY_LEN { + return None; + } + let mut h = IV; + // parameter block word 0: digest length, key length, fanout 1, depth 1 + h[0] ^= 0x0101_0000 ^ ((key.len() as u64) << 8) ^ out_len as u64; + let mut state = Blake2b { + h, + t: 0, + buf: [0; BLOCK_LEN], + buf_len: 0, + out_len, + }; + // a key is hashed as a full padded first block before any input + if !key.is_empty() { + state.buf[..key.len()].copy_from_slice(key); + state.buf_len = BLOCK_LEN; + } + Some(state) + } + + /// Absorb `input` into the hash. + pub fn update(&mut self, mut input: &[u8]) { + while !input.is_empty() { + // only flush a full buffer once more input exists; the final + // block must stay buffered so finalize can flag it as last + if self.buf_len == BLOCK_LEN { + self.t += BLOCK_LEN as u128; + let block = self.buf; + self.compress(&block, false); + self.buf_len = 0; + } + let take = (BLOCK_LEN - self.buf_len).min(input.len()); + self.buf[self.buf_len..self.buf_len + take].copy_from_slice(&input[..take]); + self.buf_len += take; + input = &input[take..]; + } + } + + /// Finish the hash and return the digest. + pub fn finalize(mut self) -> Vec { + self.t += self.buf_len as u128; + self.buf[self.buf_len..].fill(0); + let block = self.buf; + self.compress(&block, true); + let mut out = vec![0u8; self.out_len]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + chunk.copy_from_slice(&self.h[i].to_le_bytes()[..chunk.len()]); + } + out + } + + /// The compression function F from RFC 7693 section 3.2. + fn compress(&mut self, block: &[u8; BLOCK_LEN], last: bool) { + let mut m = [0u64; 16]; + for (i, word) in m.iter_mut().enumerate() { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&block[i * 8..i * 8 + 8]); + *word = u64::from_le_bytes(bytes); + } + + let mut v = [0u64; 16]; + v[..8].copy_from_slice(&self.h); + v[8..].copy_from_slice(&IV); + v[12] ^= self.t as u64; + v[13] ^= (self.t >> 64) as u64; + if last { + v[14] = !v[14]; + } + + for sigma in &SIGMA { + // columns + mix(&mut v, 0, 4, 8, 12, m[sigma[0]], m[sigma[1]]); + mix(&mut v, 1, 5, 9, 13, m[sigma[2]], m[sigma[3]]); + mix(&mut v, 2, 6, 10, 14, m[sigma[4]], m[sigma[5]]); + mix(&mut v, 3, 7, 11, 15, m[sigma[6]], m[sigma[7]]); + // diagonals + mix(&mut v, 0, 5, 10, 15, m[sigma[8]], m[sigma[9]]); + mix(&mut v, 1, 6, 11, 12, m[sigma[10]], m[sigma[11]]); + mix(&mut v, 2, 7, 8, 13, m[sigma[12]], m[sigma[13]]); + mix(&mut v, 3, 4, 9, 14, m[sigma[14]], m[sigma[15]]); + } + + for i in 0..8 { + self.h[i] ^= v[i] ^ v[i + 8]; + } + } +} + +/// The mixing function G from RFC 7693 section 3.1. +#[inline(always)] +fn mix(v: &mut [u64; 16], a: usize, b: usize, c: usize, d: usize, x: u64, y: u64) { + v[a] = v[a].wrapping_add(v[b]).wrapping_add(x); + v[d] = (v[d] ^ v[a]).rotate_right(32); + v[c] = v[c].wrapping_add(v[d]); + v[b] = (v[b] ^ v[c]).rotate_right(24); + v[a] = v[a].wrapping_add(v[b]).wrapping_add(y); + v[d] = (v[d] ^ v[a]).rotate_right(16); + v[c] = v[c].wrapping_add(v[d]); + v[b] = (v[b] ^ v[c]).rotate_right(63); +} + +/// One-shot BLAKE2b: hash `data` (keyed when `key` is non-empty) into an +/// `out_len`-byte digest. Returns `None` on an out-of-range digest or key +/// length. +pub fn hash(out_len: usize, key: &[u8], data: &[u8]) -> Option> { + let mut state = Blake2b::new(out_len, key)?; + state.update(data); + Some(state.finalize()) +} + +#[cfg(test)] +mod tests { + use super::hash; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() + } + + fn hash_hex(out_len: usize, key: &[u8], data: &[u8]) -> Option { + hash(out_len, key, data).map(|digest| hex(&digest)) + } + + #[test] + fn rfc_7693_appendix_a_abc() { + assert_eq!( + hash_hex(64, b"", b"abc").as_deref(), + Some( + "ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d1\ + 7d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923" + ) + ); + } + + #[test] + fn unkeyed_empty_input() { + assert_eq!( + hash_hex(64, b"", b"").as_deref(), + Some( + "786a02f742015903c6c6fd852552d272912f4740e15847618a86e217f71f5419\ + d25e1031afee585313896444934eb04b903a685b1448b755d56f701afe9be2ce" + ) + ); + } + + #[test] + fn truncated_digest_lengths() { + let fox: &[u8] = b"The quick brown fox jumps over the lazy dog"; + assert_eq!( + hash_hex(20, b"", fox).as_deref(), + Some("3c523ed102ab45a37d54f5610d5a983162fde84f") + ); + assert_eq!( + hash_hex(32, b"", fox).as_deref(), + Some("01718cec35cd3d796dd00020e0bfecb473ad23457d063b75eff29c0ffa2e58a9") + ); + assert_eq!( + hash_hex(48, b"", fox).as_deref(), + Some( + "b7c81b228b6bd912930e8f0b5387989691c1cee1e65aade4da3b86a3\ + c9f678fc8018f6ed9e2906720c8d2a3aeda9c03d" + ) + ); + } + + // the official blake2b-kat vectors hash the byte sequence 00 01 02 ... + // with the 64-byte key 00 01 02 ... 3f + fn kat_input(len: usize) -> Vec { + (0..len).map(|i| i as u8).collect() + } + + fn kat_key() -> Vec { + kat_input(64) + } + + #[test] + fn official_kat_keyed_empty() { + assert_eq!( + hash_hex(64, &kat_key(), b"").as_deref(), + Some( + "10ebb67700b1868efb4417987acf4690ae9d972fb7a590c2f02871799aaa4786\ + b5e996e8f0f4eb981fc214b005f42d2ff4233499391653df7aefcbc13fc51568" + ) + ); + } + + #[test] + fn official_kat_keyed_one_byte() { + assert_eq!( + hash_hex(64, &kat_key(), &kat_input(1)).as_deref(), + Some( + "961f6dd1e4dd30f63901690c512e78e4b45e4742ed197c3c5e45c549fd25f2e4\ + 187b0bc9fe30492b16b0d0bc4ef9b0f34c7003fac09a5ef1532e69430234cebd" + ) + ); + } + + #[test] + fn official_kat_keyed_block_boundaries() { + assert_eq!( + hash_hex(64, &kat_key(), &kat_input(64)).as_deref(), + Some( + "65676d800617972fbd87e4b9514e1c67402b7a331096d3bfac22f1abb95374ab\ + c942f16e9ab0ead33b87c91968a6e509e119ff07787b3ef483e1dcdccf6e3022" + ) + ); + assert_eq!( + hash_hex(64, &kat_key(), &kat_input(128)).as_deref(), + Some( + "72065ee4dd91c2d8509fa1fc28a37c7fc9fa7d5b3f8ad3d0d7a25626b57b1b44\ + 788d4caf806290425f9890a3a2a35a905ab4b37acfd0da6e4517b2525c9651e4" + ) + ); + assert_eq!( + hash_hex(64, &kat_key(), &kat_input(129)).as_deref(), + Some( + "64475dfe7600d7171bea0b394e27c9b00d8e74dd1e416a79473682ad3dfdbb70\ + 6631558055cfc8a40e07bd015a4540dcdea15883cbbf31412df1de1cd4152b91" + ) + ); + } + + #[test] + fn official_kat_keyed_255_bytes() { + assert_eq!( + hash_hex(64, &kat_key(), &kat_input(255)).as_deref(), + Some( + "142709d62e28fcccd0af97fad0f8465b971e82201dc51070faa0372aa43e9248\ + 4be1c1e73ba10906d5d1853db6a4106e0a7bf9800d373d6dee2d46d62ef2a461" + ) + ); + // keyed and truncated at the same time + assert_eq!( + hash_hex(32, &kat_key(), &kat_input(255)).as_deref(), + Some("fe7b76a61787c089141f9e10fca1e5092488d89c62ea793fb2c5b1f849b4f2cb") + ); + } + + #[test] + fn unkeyed_block_boundaries() { + assert_eq!( + hash_hex(64, b"", &kat_input(128)).as_deref(), + Some( + "2319e3789c47e2daa5fe807f61bec2a1a6537fa03f19ff32e87eecbfd64b7e0e\ + 8ccff439ac333b040f19b0c4ddd11a61e24ac1fe0f10a039806c5dcc0da3d115" + ) + ); + assert_eq!( + hash_hex(64, b"", &kat_input(129)).as_deref(), + Some( + "f59711d44a031d5f97a9413c065d1e614c417ede998590325f49bad2fd444d3e\ + 4418be19aec4e11449ac1a57207898bc57d76a1bcf3566292c20c683a5c4648f" + ) + ); + } + + #[test] + fn incremental_matches_one_shot() { + let data = kat_input(300); + let Some(whole) = hash(64, b"", &data) else { + assert!(false, "one-shot hash failed"); + return; + }; + let Some(mut state) = super::Blake2b::new(64, b"") else { + assert!(false, "state construction failed"); + return; + }; + for chunk in data.chunks(7) { + state.update(chunk); + } + assert_eq!(state.finalize(), whole); + } + + #[test] + fn rejects_out_of_range_lengths() { + assert!(hash(0, b"", b"data").is_none()); + assert!(hash(65, b"", b"data").is_none()); + assert!(hash(64, &[0u8; 65], b"data").is_none()); + assert!(hash(64, &[0u8; 64], b"data").is_some()); + assert!(hash(1, b"", b"").is_some()); + } +} diff --git a/cranelift/runtime/src/lib.rs b/cranelift/runtime/src/lib.rs index cb6f267b..cef5cdc7 100644 --- a/cranelift/runtime/src/lib.rs +++ b/cranelift/runtime/src/lib.rs @@ -11,6 +11,8 @@ #![allow(clippy::missing_safety_doc)] +pub mod argon2; +pub mod blake2b; pub mod blocking; pub mod bytes; pub mod collections; From 03e6846b4d01257300f7c914386b5c40f1cc851b Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Fri, 31 Jul 2026 05:40:43 +0000 Subject: [PATCH 2/2] expose blake2b and argon2id at the runtime ffi boundary crypto_blake2b and crypto_argon2id follow the existing crypto entries: bytes handles in, a bytes handle out, and 0 on any invalid input. the checker-side declarations that make these callable from pith land separately. --- cranelift/runtime-abi/runtime_functions.txt | 2 + cranelift/runtime/src/crypto.rs | 61 +++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/cranelift/runtime-abi/runtime_functions.txt b/cranelift/runtime-abi/runtime_functions.txt index 85ab2b94..6d4f96df 100644 --- a/cranelift/runtime-abi/runtime_functions.txt +++ b/cranelift/runtime-abi/runtime_functions.txt @@ -152,6 +152,8 @@ crypto_verify_rsa_pkcs1_sha256|pith_crypto_verify_rsa_pkcs1_sha256|I64,I64,I64|I crypto_verify_rsa_pkcs1_sha384|pith_crypto_verify_rsa_pkcs1_sha384|I64,I64,I64|I64 crypto_verify_rsa_pss_sha256|pith_crypto_verify_rsa_pss_sha256|I64,I64,I64|I64 crypto_sign_rsa_pss_sha256_pkcs8|pith_crypto_sign_rsa_pss_sha256_pkcs8|I64,I64|I64 +crypto_blake2b|pith_crypto_blake2b|I64,I64,I64|I64 +crypto_argon2id|pith_crypto_argon2id|I64,I64,I64,I64,I64,I64|I64 pith_json_fill_struct|pith_json_fill_struct|I64,I64,I64|I64 pith_json_decode_missing_error|pith_json_decode_missing_error|I64,I64|I64 byte_buffer_new|pith_byte_buffer_new||I64 diff --git a/cranelift/runtime/src/crypto.rs b/cranelift/runtime/src/crypto.rs index ab6b43fe..ce77b9ec 100644 --- a/cranelift/runtime/src/crypto.rs +++ b/cranelift/runtime/src/crypto.rs @@ -395,6 +395,67 @@ pub unsafe extern "C" fn pith_crypto_sign_rsa_pss_sha256_pkcs8(pkcs8: i64, messa sign_rsa_with(&signature::RSA_PSS_SHA256, pkcs8, message) } +/// BLAKE2b digest of `data`, keyed when `key` is non-empty. `out_len` selects +/// the digest length (1 to 64 bytes). Returns a bytes handle, or 0 on invalid +/// input. +#[no_mangle] +pub unsafe extern "C" fn pith_crypto_blake2b(data: i64, key: i64, out_len: i64) -> i64 { + let Some(data) = bytes_slice(data) else { + return 0; + }; + let Some(key) = bytes_slice(key) else { + return 0; + }; + if out_len < 1 || out_len > crate::blake2b::MAX_OUT_LEN as i64 { + return 0; + } + match crate::blake2b::hash(out_len as usize, key, data) { + Some(digest) => pith_bytes_from_vec(digest), + None => 0, + } +} + +/// Argon2id tag for `password` and `salt` with the given time cost (passes), +/// memory cost (KiB), and parallelism. Returns a bytes handle, or 0 when a +/// parameter is out of range (including the runtime's memory-cost cap). +#[no_mangle] +pub unsafe extern "C" fn pith_crypto_argon2id( + password: i64, + salt: i64, + passes: i64, + memory_kib: i64, + lanes: i64, + out_len: i64, +) -> i64 { + let Some(password) = bytes_slice(password) else { + return 0; + }; + let Some(salt) = bytes_slice(salt) else { + return 0; + }; + // the argon2 module re-validates ranges; these checks only make the + // i64 -> u32/usize conversions safe + if passes < 0 || memory_kib < 0 || lanes < 0 || out_len < 0 { + return 0; + } + if passes > u32::MAX as i64 || memory_kib > u32::MAX as i64 || lanes > u32::MAX as i64 { + return 0; + } + match crate::argon2::argon2id( + password, + salt, + b"", + b"", + passes as u32, + memory_kib as u32, + lanes as u32, + out_len as usize, + ) { + Ok(tag) => pith_bytes_from_vec(tag), + Err(_) => 0, + } +} + #[no_mangle] pub extern "C" fn pith_os_cert_roots_pem() -> *mut i8 { let mut candidates = Vec::new();