Writing Constant-Time Rust Is Not Enough | Emanuele's Log %E2%9A%9B%EF%B8%8F"><br>Contents
Compilers rewrite programs all the time.<br>Rust code becomes MIR, MIR becomes LLVM IR, LLVM runs optimization passes, and eventually machine code comes out. The usual contract is simple: the optimized program should compute the same result as the original one, only faster or smaller.<br>Constant-time cryptography asks for one more thing.<br>It is not enough that the program returns the right value. It also matters which addresses the CPU touches while computing that value. Two executions can return the same answer and still behave differently in the cache.<br>That creates an interesting question:<br>Can Rust code look constant-time at the source level, but compile into a binary whose memory access pattern depends on a secret?<br>The investigation starts from a standard constant-time selection idiom: load both candidate values first, then let the secret choose only between values already in registers. Then aliasing is made relevant, the optimized assembly is checked, and the timing behavior is measured.<br>The code and artifacts for the experiments live in the ct-rust-verifier repository.<br>Starting From The Desired Shape<br>A common constant-time trick is to load both possible values, then select one of the already-loaded values in registers:<br>fn ct_select_u8(choice: u8, a: u8, b: u8) -> u8 {<br>let mask = 0u8.wrapping_sub(choice & 1);<br>(a & !mask) | (b & mask)
The important property is not just “no branch”. It is also “same memory access pattern”.<br>If choice is secret, this source shape is fine:<br>let av = *a;<br>let bv = *b;<br>ct_select_u8(choice, av, bv)
Both pointers are loaded every time. The secret only chooses between values that are already in registers. At source level, this is the shape the experiment wants to preserve.<br>But there is another shape that is not fine:<br>selected = choice ? a : b<br>load *selected
That can be branchless too. On AArch64, for example, the address selection can use csel, a conditional select instruction. But this version loads only one address. If one address is cache-hot and the other is cache-cold, timing can reveal the secret choice.<br>The source-level difference looks small, but the machine-level memory-access pattern is not the same. The rest of the investigation is about whether LLVM can legally move from the first shape to the second.<br>Making Aliasing Matter<br>The test case uses this source-level pattern:<br>let av = *a;<br>*out = 0;<br>let bv = *b;<br>ct_select_u8(choice, av, bv)
There are two loads and one store. The store is there because it makes the optimizer care about whether out can overlap with a or b. If overlap is possible, the compiler has to be conservative around the store. If overlap is ruled out, the compiler has more freedom.<br>This gives a simple strategy: keep the source access shape the same, but change what aliasing facts are available to the optimizer.<br>The first version keeps raw pointers:<br>pub unsafe fn raw_interleaved_select(<br>choice: u8,<br>a: *const u8,<br>b: *const u8,<br>out: *mut u8,<br>) -> u8 {<br>let av = *a;<br>*out = 0;<br>let bv = *b;<br>ct_select_u8(choice, av, bv)
The second version first converts the raw pointers into Rust references:<br>pub unsafe fn unsafe_ref_interleaved_select(<br>choice: u8,<br>a: *const u8,<br>b: *const u8,<br>out: *mut u8,<br>) -> u8 {<br>let a_ref = &*a;<br>let b_ref = &*b;<br>let out_ref = &mut *out;
ref_interleaved_select(choice, a_ref, b_ref, out_ref)
The helper receives references and performs the same interleaved access pattern:<br>fn ref_interleaved_select(choice: u8, a: &u8, b: &u8, out: &mut u8) -> u8 {<br>let av = *a;<br>*out = 0;<br>let bv = *b;<br>ct_select_u8(choice, av, bv)
At the Rust source level, both versions still look like fixed memory access: load a, store to out, load b, then select in registers.<br>At this point there is no result yet. Both Rust snippets still read like the same fixed-access algorithm. The result appears only after optimization.<br>First Result: The Assembly Shape Changes<br>The optimized assembly is where the first finding appears.<br>The raw-pointer version keeps both loads:<br>ldrb w8, [x1]<br>strb wzr, [x3]<br>ldrb w9, [x2]<br>tst w0, #0x1<br>csel w0, w8, w9, eq<br>ret<br>The reference version selects the address first, then loads once:<br>tst w0, #0x1<br>csel x8, x1, x2, eq<br>ldrb w0, [x8]<br>strb wzr, [x3]<br>ret<br>This is the transform the experiment is looking for:<br>load a; load b; select value
becomes:<br>select address; load selected address
If choice is secret, this changes the side-channel behavior of the program. The source-level constant-time argument says “both addresses are loaded”; the binary does not do that in the reference-based version.<br>Why Rust Semantics Matter<br>The assembly difference points back to Rust semantics.<br>The raw-pointer version and the reference version are not equivalent inputs to the optimizer. Forming references tells the compiler more about the memory being accessed.<br>When Rust lowers references to LLVM IR, it can attach facts such as:<br>noalias<br>nonnull<br>dereferenceable<br>readonly<br>writeonly<br>alias.scope<br>These facts...