metamorphic_crypto/stack.rs
1//! Large-stack execution guard for lattice signing/keygen.
2//!
3//! ML-DSA (the FIPS 204 module-lattice signature) allocates large intermediate
4//! working sets *on the stack* inside the upstream `ml-dsa` crate: the hedged
5//! signing path expands the public matrix `A` and buffers several polynomial
6//! vectors through its rejection-sampling loop, and key generation / verifying-key
7//! expansion do the same. Those arrays are fixed-size stack allocations in code we
8//! do not control (`ml-dsa`), so they cannot be boxed onto the heap from this
9//! crate.
10//!
11//! On runtimes with a small thread stack this overflows and faults the guard page.
12//! The two constrained runtimes we ship into have different characteristics:
13//!
14//! * **BEAM dirty-CPU scheduler** (via the Elixir NIFs): the dirty scheduler
15//! thread's default stack (`+sssdcpu`, ~320 KB) is far too small and the whole
16//! VM dies with SIGBUS. This module provides the fix: run the operation on a
17//! dedicated worker thread with a generous stack and block the scheduler on the
18//! join — exactly the kind of bounded, blocking work dirty schedulers exist for.
19//!
20//! * **Browser WASM** (via [`crate::wasm`]): the shadow stack is a fixed,
21//! build-time size with no threads, so the fix there is a *linker* stack-size
22//! bump rather than a worker thread. That is why this module is gated
23//! `#[cfg(not(target_arch = "wasm32"))]` — see the crate's `.cargo/config.toml`
24//! for the WASM side.
25//!
26//! Every consumer that drives ML-DSA signing or keygen from a small-stack native
27//! thread should route the call through [`on_signing_stack`] rather than
28//! re-implementing the guard or pushing a `+sssdcpu` requirement onto its own
29//! `vm.args`. Verification uses far less stack and does not need this.
30
31/// Recommended stack size, in bytes, for a thread that runs ML-DSA signing or
32/// key generation.
33///
34/// 32 MiB comfortably covers ML-DSA-87 (Cat-5, the largest parameter set) with
35/// generous headroom against future footprint growth in the upstream `ml-dsa`
36/// crate. The reservation is cheap: only pages actually touched are committed by
37/// the OS, so an unused 32 MiB stack costs (virtually) nothing in RSS.
38pub const RECOMMENDED_SIGNING_STACK_BYTES: usize = 32 * 1024 * 1024;
39
40/// Run `f` on a dedicated worker thread with [`RECOMMENDED_SIGNING_STACK_BYTES`]
41/// of stack, returning its value.
42///
43/// This is the shared, audited guard for ML-DSA signing / keygen on small-stack
44/// native runtimes (notably the BEAM dirty-CPU scheduler). The calling thread
45/// blocks on the worker's join, so this is synchronous from the caller's point of
46/// view — it simply borrows a bigger stack for the duration of `f`.
47///
48/// The closure must return owned, `Send` values; keep any FFI term/handle
49/// construction on the caller so only plain data crosses the thread boundary. If
50/// `f` panics, the panic is propagated to the calling thread unchanged (via
51/// [`std::panic::resume_unwind`]), so `catch_unwind` and NIF panic handling behave
52/// exactly as if `f` had run inline.
53///
54/// # Panics
55///
56/// Panics if the worker thread cannot be spawned (e.g. the OS refuses the stack
57/// reservation), or re-raises any panic that occurred inside `f`.
58///
59/// # Examples
60///
61/// ```
62/// use metamorphic_crypto::on_signing_stack;
63///
64/// let sig = on_signing_stack(|| vec![0u8; 32]);
65/// assert_eq!(sig.len(), 32);
66/// ```
67pub fn on_signing_stack<F, T>(f: F) -> T
68where
69 F: FnOnce() -> T + Send,
70 T: Send,
71{
72 std::thread::scope(|scope| {
73 std::thread::Builder::new()
74 .stack_size(RECOMMENDED_SIGNING_STACK_BYTES)
75 .spawn_scoped(scope, f)
76 .expect("failed to spawn signing worker thread")
77 .join()
78 .unwrap_or_else(|payload| std::panic::resume_unwind(payload))
79 })
80}