diff --git a/.github/workflows/pr_main.yaml b/.github/workflows/pr_main.yaml index cb10ec72a..a4554fda2 100644 --- a/.github/workflows/pr_main.yaml +++ b/.github/workflows/pr_main.yaml @@ -131,6 +131,9 @@ jobs: - name: Run CLI tests run: cargo test -p cli + - name: Run syscalls host tests (keccak differential vs sha3) + run: make test-syscalls + # "Test" is a required check — keep this name to avoid branch protection changes. # This gate job passes only when CLI, executor, disk-spill, and prover tests succeed. test: diff --git a/Makefile b/Makefile index 110c2d31f..12ac291f5 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: deps deps-linux deps-macos compile-programs-asm compile-programs-rust compile-bench \ compile-programs compile-recursion-elfs clean-asm clean-rust clean-bench clean-shared \ clean-recursion-elfs clean test test-asm \ -test-rust test-ethrex test-executor test-flamegraph flamegraph-prover test-profile-recursion test-profile-recursion-single test-profile-recursion-multi \ +test-rust test-ethrex test-executor test-syscalls test-flamegraph flamegraph-prover test-profile-recursion test-profile-recursion-single test-profile-recursion-multi \ test-fast test-prover test-prover-all test-prover-debug test-disk-spill test-math-cuda test-cuda-integration test-cuda-fallback \ test-prover-cuda test-prover-comprehensive-cuda \ bench-math-cuda bench-prover bench-prover-cuda build check clippy fmt lint regen-ethrex-fixtures \ @@ -293,7 +293,16 @@ update-ethrex-fixture-checksums: check-ethrex-fixture-checksums: python3 tooling/ethrex-fixtures/update_readme_checksums.py --check -test: compile-programs +# The syscalls crate is excluded from the workspace (riscv-only bare-metal +# entrypoints/allocator that assemble only for the guest target — see the root +# Cargo.toml exclude), so the root `cargo test` never reaches its host +# differential tests (the keccak sponge vs sha3 reference). Run them explicitly +# in the crate dir; wired into `test` below and run as a dedicated step +# in CI's cli-test job (pr_main.yaml). +test-syscalls: + cd syscalls && cargo test + +test: compile-programs test-syscalls cargo test # === Quick test shortcuts === diff --git a/crypto/crypto/src/hash/platform_keccak.rs b/crypto/crypto/src/hash/platform_keccak.rs index 199c69625..3c3cb081e 100644 --- a/crypto/crypto/src/hash/platform_keccak.rs +++ b/crypto/crypto/src/hash/platform_keccak.rs @@ -11,6 +11,15 @@ mod imp { }; use lambda_vm_syscalls::keccak::Keccak256 as SyscallKeccak256; + // INVARIANT (load-bearing): this adapter must remain a PURE PASSTHROUGH of + // `SyscallKeccak256`. The TypeId specializations in + // crypto/crypto/src/merkle_tree/backends/field_element_vector.rs bypass it + // and drive the syscall sponge directly, on the assumption that both paths + // hash identically. Adding ANY behavior here (a domain prefix, extra + // absorption, a different reset policy) silently desyncs the specialized + // branches from the generic path — and the failure surfaces as in-guest + // proof rejection, not as a host test failure. + #[derive(Clone, Default)] pub struct PlatformKeccak256(SyscallKeccak256); diff --git a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs index d60419cf4..6d0cc6491 100644 --- a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs +++ b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs @@ -9,6 +9,88 @@ use math::{ traits::AsBytes, }; +#[cfg(target_arch = "riscv64")] +use crate::hash::platform_keccak::PlatformKeccak256; +#[cfg(target_arch = "riscv64")] +use core::any::TypeId; +#[cfg(target_arch = "riscv64")] +use lambda_vm_syscalls::keccak::Keccak256 as SyscallKeccak256; + +/// Absorb `feed`'s byte stream into a fresh `D` and return the digest as a +/// fixed `[u8; NUM_BYTES]`. +/// +/// On the riscv64 guest, when `D` is the platform keccak digest and the node +/// is 32 bytes, this drives the syscall sponge directly and squeezes straight +/// into the result array. That skips the `Digest::finalize` blanket, which +/// allocates a zeroed `GenericArray`, has the adapter fill a local `[u8; 32]` +/// and copy it into that `Output`, then leaves the caller to copy the `Output` +/// once more into its own array — two 32-byte memcpys plus a 32-byte memset of +/// pure plumbing around the one permutation. Byte-identical to the generic +/// path; every other digest / node size (and the entire host build) takes the +/// generic path unchanged. +/// +/// DO NOT replace this `TypeId` dispatch with a generic `Digest::finalize_into` +/// fix "at the adapter altitude" — that exact refactor was implemented and +/// MEASURED SLOWER on the guest across every formulation tried (best: +/// +60k min = +0.14%, +1.25M blowup8 = +0.48%), including `#[inline]` +/// adapters and a check-free `AsMut` output conversion. The residual is +/// intrinsic: `FixedOutput::finalize_into` moves the 208-byte sponge by value +/// through the newtype + trait layer into a non-inlined cross-crate call, and +/// without LTO the placement isn't elided; the direct branch below builds the +/// sponge in place at the call's ABI slot. Deleting the dispatch also cannot +/// remove the `'static` bounds — `hash_new_parent_bytes` needs them regardless. +#[inline] +fn hash_streamed( + feed: impl Fn(&mut dyn FnMut(&[u8])), +) -> [u8; NUM_BYTES] { + #[cfg(target_arch = "riscv64")] + if NUM_BYTES == 32 && TypeId::of::() == TypeId::of::() { + let mut hasher = SyscallKeccak256::new(); + feed(&mut |bytes| hasher.update(bytes)); + let mut result = [0u8; NUM_BYTES]; + // NUM_BYTES == 32 in this branch, so the slice is exactly a [u8; 32]. + let out: &mut [u8; 32] = (&mut result[..]).try_into().unwrap(); + hasher.finalize(out); + return result; + } + + let mut hasher = D::new(); + feed(&mut |bytes| hasher.update(bytes)); + let mut result_hash = [0_u8; NUM_BYTES]; + result_hash.copy_from_slice(&hasher.finalize()); + result_hash +} + +/// Hash a Merkle parent — always exactly two concatenated 32-byte nodes. +/// +/// On the riscv64 guest, when `D` is the platform keccak digest and nodes are +/// 32 bytes, this is one fixed-shape 64-byte compression ([`keccak256_pair`]): +/// a single permutation with the input lanes and padding written straight into +/// the state, skipping the incremental sponge's per-byte absorb, running +/// offset, and separate padding pass. Byte-identical to streaming both nodes +/// through the digest; every other digest / node size (and the host build) +/// takes the generic streaming-and-finalize path unchanged. +#[inline] +fn hash_new_parent_bytes( + left: &[u8; NUM_BYTES], + right: &[u8; NUM_BYTES], +) -> [u8; NUM_BYTES] { + #[cfg(target_arch = "riscv64")] + if NUM_BYTES == 32 && TypeId::of::() == TypeId::of::() { + let l: &[u8; 32] = left[..].try_into().unwrap(); + let r: &[u8; 32] = right[..].try_into().unwrap(); + let hash = lambda_vm_syscalls::keccak::keccak256_pair(l, r); + let mut result = [0u8; NUM_BYTES]; + result.copy_from_slice(&hash); + return result; + } + + hash_streamed::(|sink| { + sink(left); + sink(right); + }) +} + /// A backend for Merkle trees that uses fixed-size pairs of field elements. /// This is more efficient than `FieldElementVectorBackend` when the batch size is always 2, /// as it avoids Vec allocation overhead. @@ -27,7 +109,7 @@ impl Default for FieldElementPairBackend IsMerkleTreeBackend +impl IsMerkleTreeBackend for FieldElementPairBackend where F: IsField, @@ -38,21 +120,14 @@ where type Data = [FieldElement; 2]; fn hash_data(input: &[FieldElement; 2]) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - input[0].stream_bytes(&mut |b| hasher.update(b)); - input[1].stream_bytes(&mut |b| hasher.update(b)); - let mut result_hash = [0_u8; NUM_BYTES]; - result_hash.copy_from_slice(&hasher.finalize()); - result_hash + hash_streamed::(|sink| { + input[0].stream_bytes(sink); + input[1].stream_bytes(sink); + }) } fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - hasher.update(left); - hasher.update(right); - let mut result_hash = [0_u8; NUM_BYTES]; - result_hash.copy_from_slice(&hasher.finalize()); - result_hash + hash_new_parent_bytes::(left, right) } } @@ -71,7 +146,7 @@ impl Default for FieldElementVectorBackend } } -impl FieldElementVectorBackend +impl FieldElementVectorBackend where [u8; NUM_BYTES]: From>, { @@ -80,15 +155,11 @@ where /// once, avoiding per-element allocations while staying consistent with the /// backend's hash function. pub fn hash_bytes(data: &[u8]) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - hasher.update(data); - let mut result = [0u8; NUM_BYTES]; - result.copy_from_slice(&hasher.finalize()); - result + hash_streamed::(|sink| sink(data)) } } -impl FieldElementVectorBackend +impl FieldElementVectorBackend where F: IsField, FieldElement: AsBytes, @@ -100,17 +171,15 @@ where /// `hash_data(&[a, b].concat())`: the sponge absorbs the same element bytes /// in the same order, just without the intermediate `Vec`. pub fn hash_data_from_slices(a: &[FieldElement], b: &[FieldElement]) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - for element in a.iter().chain(b.iter()) { - element.stream_bytes(&mut |bytes| hasher.update(bytes)); - } - let mut result_hash = [0_u8; NUM_BYTES]; - result_hash.copy_from_slice(&hasher.finalize()); - result_hash + hash_streamed::(|sink| { + for element in a.iter().chain(b.iter()) { + element.stream_bytes(sink); + } + }) } } -impl IsMerkleTreeBackend +impl IsMerkleTreeBackend for FieldElementVectorBackend where F: IsField, @@ -129,12 +198,7 @@ where } fn hash_new_parent(left: &[u8; NUM_BYTES], right: &[u8; NUM_BYTES]) -> [u8; NUM_BYTES] { - let mut hasher = D::new(); - hasher.update(left); - hasher.update(right); - let mut result_hash = [0_u8; NUM_BYTES]; - result_hash.copy_from_slice(&hasher.finalize()); - result_hash + hash_new_parent_bytes::(left, right) } } diff --git a/syscalls/Cargo.lock b/syscalls/Cargo.lock new file mode 100644 index 000000000..34e481dd8 --- /dev/null +++ b/syscalls/Cargo.lock @@ -0,0 +1,406 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "keccak", + "lazy_static", + "rand", + "rand_chacha", + "riscv", + "sha3", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/syscalls/Cargo.toml b/syscalls/Cargo.toml index cbc9c37a3..0460a2435 100644 --- a/syscalls/Cargo.toml +++ b/syscalls/Cargo.toml @@ -11,3 +11,9 @@ getrandom = { version = "0.3.4", default-features = false } getrandom_v2 = {version = "0.2.15", features = ["custom"], package = "getrandom"} lazy_static = "1.5.0" rand = "0.9.2" + +[dev-dependencies] +keccak = "0.1" +# Stable stream across versions (unlike StdRng), so fuzz-case seeds stay reproducible. +rand_chacha = "0.9" +sha3 = "0.10" diff --git a/syscalls/src/allocator.rs b/syscalls/src/allocator.rs index 08dbb2d92..78b2933e5 100644 --- a/syscalls/src/allocator.rs +++ b/syscalls/src/allocator.rs @@ -1,7 +1,10 @@ use embedded_alloc::TlsfHeap as Heap; use riscv as _; -#[global_allocator] +// Only the guest routes Rust allocations through this heap; on host (e.g. +// `cargo test` for the sponge's differential tests) the attribute would hijack +// the test harness's allocator with a never-initialized heap and abort. +#[cfg_attr(target_arch = "riscv64", global_allocator)] static HEAP: Heap = Heap::empty(); const MAX_MEMORY_SIZE: usize = 0xC000_0000; diff --git a/syscalls/src/keccak.rs b/syscalls/src/keccak.rs index 56408e339..9fe543c59 100644 --- a/syscalls/src/keccak.rs +++ b/syscalls/src/keccak.rs @@ -9,27 +9,53 @@ //! let digest = lambda_vm_syscalls::keccak::keccak256(b"hello"); //! ``` //! +//! The sponge absorbs IN PLACE: the `[u64; 25]` state is the only buffer, and +//! input bytes are XORed directly into its rate lanes at a running offset +//! (tiny-keccak's design). There is no staging block, no per-block copy, and +//! no lane re-extraction — the permutation cost is the ecall itself, so every +//! byte moved around it is pure overhead the VM retires as real cycles. +//! +//! The VM traps on unaligned doubleword loads, so the whole-lane fast path is +//! gated on the input pointer's runtime alignment; misaligned input falls back +//! to byte-wise absorption. Correctness never depends on alignment. +//! //! On a non-`riscv64` host, `keccak_permute` panics — this module is only //! meant to be used from guest programs (compiled to `riscv64im-lambda-vm-elf`). +#[cfg(not(all(test, not(target_arch = "riscv64"))))] use crate::syscalls::keccak_permute; +/// Software Keccak-f[1600] so host `cargo test` can exercise the sponge's +/// absorption/padding/squeezing against a reference implementation — the real +/// permutation is the VM ecall, unavailable off-guest. Guest builds and host +/// non-test builds are untouched. +#[cfg(all(test, not(target_arch = "riscv64")))] +fn keccak_permute(state: &mut [u64; 25]) { + keccak::f1600(state); +} + /// Keccak-256 sponge rate in bytes (1088 bits = 136 bytes; capacity = 512 bits). const RATE_BYTES: usize = 136; +/// Rate lanes (17 for r=1088). +const RATE_LANES: usize = RATE_BYTES / 8; + /// Keccak-256 domain-separator byte (per FIPS 202 / Ethereum convention). /// Note: this is plain Keccak (0x01), not SHA-3 (0x06). const DELIMITER: u8 = 0x01; -/// Last padding byte (set high bit of final rate byte). -const FINAL_PAD_BIT: u8 = 0x80; +/// Final padding bit (high bit of the last rate byte), pre-shifted to its +/// position in the last rate lane. When the delimiter also lands in byte 135 +/// the two XORs combine to the single-byte `0x81` pad, per pad10*1. +const FINAL_PAD_LANE_BIT: u64 = (0x80u64) << 56; -/// Incremental Keccak-256 hasher. +/// Incremental Keccak-256 hasher; the state doubles as the absorption buffer. #[derive(Clone)] pub struct Keccak256 { state: [u64; 25], - buf: [u8; RATE_BYTES], - buf_len: usize, + /// Byte offset into the rate region where the next input byte XORs in. + /// Invariant between calls: `offset < RATE_BYTES`. + offset: usize, } impl Default for Keccak256 { @@ -42,59 +68,97 @@ impl Keccak256 { pub fn new() -> Self { Self { state: [0; 25], - buf: [0; RATE_BYTES], - buf_len: 0, + offset: 0, + } + } + + /// XOR one byte into the rate region at `self.offset` (no offset advance). + #[inline(always)] + fn xor_byte_at_offset(&mut self, b: u8) { + self.state[self.offset / 8] ^= u64::from(b) << ((self.offset % 8) * 8); + } + + /// Absorb one byte, permuting when the rate fills. + #[inline(always)] + fn absorb_byte(&mut self, b: u8) { + self.xor_byte_at_offset(b); + self.offset += 1; + if self.offset == RATE_BYTES { + keccak_permute(&mut self.state); + self.offset = 0; } } /// Absorb more input into the sponge. pub fn update(&mut self, mut input: &[u8]) { - if self.buf_len > 0 { - let take = (RATE_BYTES - 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..]; - if self.buf_len == RATE_BYTES { - let block = self.buf; - self.absorb_block(&block); - self.buf_len = 0; + while !input.is_empty() { + // Whole-lane fast path: sponge offset on a lane boundary AND input + // pointer 8-aligned (the VM traps on unaligned doubleword loads). + // LE-only by construction: the raw `*const u64` read below equals the + // required little-endian lane value only on a little-endian target, so + // gate on it. `cfg!(target_endian = ...)` is a compile-time constant + // that folds away on every real target (riscv64im-lambda-vm and the + // host CI targets are all little-endian), leaving codegen unchanged; + // the byte-wise fallback is endian-correct everywhere. + if cfg!(target_endian = "little") + && self.offset % 8 == 0 + && (input.as_ptr() as usize) % 8 == 0 + && input.len() >= 8 + { + let lanes_left = (RATE_BYTES - self.offset) / 8; + let take = lanes_left.min(input.len() / 8); + let base = self.offset / 8; + for i in 0..take { + // SAFETY: `input.as_ptr()` is 8-aligned (checked above) and + // `(i + 1) * 8 <= input.len()`, so this reads 8 in-bounds + // bytes at an aligned address; any bit pattern is a valid u64. + let lane = unsafe { (input.as_ptr().add(i * 8) as *const u64).read() }; + self.state[base + i] ^= lane; + } + self.offset += take * 8; + input = &input[take * 8..]; + if self.offset == RATE_BYTES { + keccak_permute(&mut self.state); + self.offset = 0; + } + } else { + // Byte-wise fallback for misaligned input. A middle path that + // assembled each lane with `from_le_bytes` was tried in three + // formulations and measured +4.7% cycles at the blowup8 preset + // (see PR #847's measurement notes): on this + // 1-cycle-per-instruction VM the extra shifts/ORs cost more than + // the byte loop they replace, so don't re-propose it. + self.absorb_byte(input[0]); + input = &input[1..]; } } - while input.len() >= RATE_BYTES { - let mut block = [0u8; RATE_BYTES]; - block.copy_from_slice(&input[..RATE_BYTES]); - self.absorb_block(&block); - input = &input[RATE_BYTES..]; - } - if !input.is_empty() { - self.buf[..input.len()].copy_from_slice(input); - self.buf_len = input.len(); - } } /// Finalize the sponge and write the 32-byte digest into `output`. pub fn finalize(mut self, output: &mut [u8; 32]) { - // Pad: append delimiter byte, zeros, final pad bit at the rate boundary. - let mut last = [0u8; RATE_BYTES]; - last[..self.buf_len].copy_from_slice(&self.buf[..self.buf_len]); - last[self.buf_len] = DELIMITER; - last[RATE_BYTES - 1] |= FINAL_PAD_BIT; - self.absorb_block(&last); - - // Squeeze the first 32 bytes (4 u64 lanes). - for (i, lane) in self.state[..4].iter().enumerate() { - output[i * 8..(i + 1) * 8].copy_from_slice(&lane.to_le_bytes()); - } + // Pad in place: delimiter at the current offset, final bit at the end + // of the rate. `offset < RATE_BYTES` always holds here, so the padded + // block is exactly the one permutation below. + self.xor_byte_at_offset(DELIMITER); + self.state[RATE_LANES - 1] ^= FINAL_PAD_LANE_BIT; + keccak_permute(&mut self.state); + + squeeze32_into(&self.state, output); } +} - fn absorb_block(&mut self, block: &[u8; RATE_BYTES]) { - // XOR the block into the rate portion of the state (first 17 lanes for r=1088). - for (i, lane) in self.state.iter_mut().take(RATE_BYTES / 8).enumerate() { - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&block[i * 8..(i + 1) * 8]); - *lane ^= u64::from_le_bytes(bytes); - } - keccak_permute(&mut self.state); +/// Squeeze the 32-byte Keccak-256 digest out of a permuted state into `out`: +/// rate lanes 0..4, little-endian. Shared by the streaming +/// [`Keccak256::finalize`] and the fixed-shape [`keccak256_pair`] so the squeeze +/// loop lives in exactly one place. Writes into a caller-owned buffer (rather +/// than returning `[u8; 32]`) so `finalize` fills its `output` reference in +/// place, keeping the guest codegen identical to the pre-dedup loop. Do NOT +/// "simplify" this to a return-value form: that shape was measured at +81k +/// guest cycles (min preset) from the extra stack temporary it introduces. +#[inline(always)] +fn squeeze32_into(state: &[u64; 25], out: &mut [u8; 32]) { + for (i, chunk) in out.chunks_exact_mut(8).enumerate() { + chunk.copy_from_slice(&state[i].to_le_bytes()); } } @@ -106,3 +170,166 @@ pub fn keccak256(input: &[u8]) -> [u8; 32] { hasher.finalize(&mut out); out } + +/// Keccak-256 of exactly two concatenated 32-byte nodes (64 bytes) — the fixed +/// shape of every Merkle parent hash. 64 bytes fit the 136-byte rate in one +/// block, so this skips the incremental sponge entirely: load the eight data +/// lanes straight from `left`/`right`, XOR the `pad10*1` bits in place, run one +/// permutation, squeeze four lanes. Byte-identical to feeding `left` then +/// `right` through the streaming [`Keccak256`] and finalizing. +/// +/// The nodes are only byte-aligned, so lanes are assembled with `from_le_bytes` +/// over owned arrays — never an aligned doubleword load, which the VM would trap +/// on at a misaligned address. +pub fn keccak256_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + let mut state = [0u64; 25]; + // Bytes 0..64 span rate lanes 0..8: lanes 0..4 from `left`, 4..8 from `right`. + for i in 0..4 { + let l: &[u8; 8] = left[i * 8..i * 8 + 8].try_into().unwrap(); + state[i] = u64::from_le_bytes(*l); + let r: &[u8; 8] = right[i * 8..i * 8 + 8].try_into().unwrap(); + state[4 + i] = u64::from_le_bytes(*r); + } + // pad10*1 for a 64-byte message at rate 136: delimiter at byte 64 (lane 8, + // low byte) and the final bit at the last rate byte (byte 135, lane 16 high + // byte). Both target lanes are still zero, so XOR == assignment. + state[8] ^= u64::from(DELIMITER); + state[RATE_LANES - 1] ^= FINAL_PAD_LANE_BIT; + keccak_permute(&mut state); + + let mut out = [0u8; 32]; + squeeze32_into(&state, &mut out); + out +} + +/// Host-only differential tests: the sponge (absorption chunking, padding, +/// squeezing, the fixed-shape pair path) must produce digests byte-identical +/// to the reference `sha3::Keccak256` for every input length and every way of +/// slicing the input across `update` calls. The permutation itself is the +/// software `keccak::f1600` here (see `keccak_permute` above); on-guest the +/// end-to-end oracle is proof-blob acceptance (any digest difference diverges +/// the Fiat-Shamir transcript and fails verification loudly). +/// +/// What these tests do NOT cover: the `keccak_permute` ecall itself and the +/// `#[cfg(target_arch = "riscv64")]` specialized call sites (the Merkle +/// backends' TypeId branches) — those are validated only by the blob oracle. +/// The generic-vs-specialized equivalence additionally rests on +/// `PlatformKeccak256` staying a pure passthrough of this sponge; see the +/// INVARIANT note in crypto/crypto/src/hash/platform_keccak.rs. +#[cfg(all(test, not(target_arch = "riscv64")))] +mod tests { + use super::*; + use rand::{Rng, SeedableRng}; + use rand_chacha::ChaCha8Rng; + use sha3::{Digest, Keccak256 as RefKeccak256}; + + fn reference(input: &[u8]) -> [u8; 32] { + RefKeccak256::digest(input).into() + } + + /// Every length from empty through three full rate blocks (+2), so every + /// padding boundary (135/136/137, 271/272/273, …) is hit. + #[test] + fn one_shot_matches_reference_for_all_lengths() { + let data: Vec = (0..3 * RATE_BYTES + 2) + .map(|i| (i * 31 + 7) as u8) + .collect(); + for len in 0..=data.len() { + assert_eq!( + keccak256(&data[..len]), + reference(&data[..len]), + "digest mismatch at len={len}" + ); + } + } + + /// Differential chunking fuzz: random sub-slices (random start => misaligned + /// pointers exercising the byte fallback) fed through `update` in random + /// pieces must match the one-shot reference digest. + #[test] + fn chunked_misaligned_updates_match_reference() { + let data: Vec = (0..1500).map(|i| (i * 131 + 17) as u8).collect(); + let mut rng = ChaCha8Rng::seed_from_u64(0x9E37_79B9_7F4A_7C15); + for case in 0..300 { + let len = rng.random_range(0..data.len()); + let start = rng.random_range(0..data.len() - len + 1); + let slice = &data[start..start + len]; + + let mut hasher = Keccak256::new(); + let mut fed = 0; + while fed < slice.len() { + let n = 1 + rng.random_range(0..(slice.len() - fed).min(200)); + hasher.update(&slice[fed..fed + n]); + fed += n; + } + let mut out = [0u8; 32]; + hasher.finalize(&mut out); + assert_eq!( + out, + reference(slice), + "chunked digest mismatch: case={case} start={start} len={len}" + ); + } + } + + /// Structural (allocator-independent) coverage of BOTH absorb paths. The + /// other tests feed `Vec` slices, whose base alignment is up to the + /// allocator — on current platforms they happen to be 8-aligned, so the + /// whole-lane fast path is exercised only by luck. Here a `repr(align(8))` + /// buffer GUARANTEES the aligned fast path, and a +1-offset view of the + /// same bytes GUARANTEES the byte-wise fallback, across all padding + /// boundaries. + #[test] + fn aligned_and_misaligned_paths_match_reference() { + #[repr(align(8))] + struct Aligned([u8; 3 * RATE_BYTES + 9]); + + let mut buf = Aligned([0u8; 3 * RATE_BYTES + 9]); + for (i, b) in buf.0.iter_mut().enumerate() { + *b = (i * 131 + 17) as u8; + } + assert_eq!(buf.0.as_ptr() as usize % 8, 0, "repr(align(8)) must hold"); + + for len in [0, 1, 7, 8, 9, 63, 64, 135, 136, 137, 271, 272, 273, 400] { + let aligned = &buf.0[..len]; + assert_eq!(keccak256(aligned), reference(aligned), "aligned len={len}"); + let misaligned = &buf.0[1..1 + len]; + assert_eq!( + misaligned.as_ptr() as usize % 8, + 1, + "offset view must be misaligned" + ); + assert_eq!( + keccak256(misaligned), + reference(misaligned), + "misaligned len={len}" + ); + } + } + + /// The fixed-shape parent path must equal hashing the 64-byte concatenation. + #[test] + fn pair_matches_reference() { + let mut rng = ChaCha8Rng::seed_from_u64(0xD1B5_4A32_D192_ED03); + for case in 0..64 { + let mut left = [0u8; 32]; + let mut right = [0u8; 32]; + for b in left.iter_mut().chain(right.iter_mut()) { + *b = rng.random(); + } + let mut concat = [0u8; 64]; + concat[..32].copy_from_slice(&left); + concat[32..].copy_from_slice(&right); + assert_eq!( + keccak256_pair(&left, &right), + reference(&concat), + "pair digest mismatch: case={case}" + ); + assert_eq!( + keccak256_pair(&left, &right), + keccak256(&concat), + "pair vs streaming sponge mismatch: case={case}" + ); + } + } +} diff --git a/syscalls/src/lib.rs b/syscalls/src/lib.rs index d0ff4418c..767f0ff71 100644 --- a/syscalls/src/lib.rs +++ b/syscalls/src/lib.rs @@ -1,5 +1,10 @@ pub mod allocator; pub mod ef_io; +// Guest-only: `_start` + the imported `main` are entry symbols that collide +// with the host C runtime / test harness (Linux errors with "entry symbol +// `main` declared multiple times"; macOS happens to tolerate it, which is why +// host tests passed locally). Same treatment as the global allocator gating. +#[cfg(target_arch = "riscv64")] pub mod entrypoint; pub mod keccak; pub mod random;