polycratia

Constant-time comparison and erasure the compiler cannot delete

· 8 min read

Two defects outlive most of the code that still contains them. A MAC compared with memcmp returns at the first differing byte, so anyone who can time the call learns how many leading bytes they guessed right, and recovers the tag one byte at a time (CWE-208). A key cleared with memset at the end of a function is a store into a buffer that is dead afterwards, and the optimizer is permitted to delete it, which leaves the key sitting in memory for a core dump or the next allocation to find (CWE-14). Neither needs a clever fix. Both need a fix the compiler is not permitted to undo.

I have been shipping payment and crypto systems since 2018, and in that time these same two lines have turned up in webhook signature verification, in session token checks, and in custodial wallet code handling key material. They are not exotic. They are what you write when you are thinking about the protocol and not about the code generator. I eventually pulled the fix into a small header so I would stop re-deriving it: ctsafe, header-only, C++17, no allocation, no exceptions. The API is trivial. The threat model is not, and that is what the rest of this post is about.

The shape they ship in#

cpp
if (memcmp(mac, expected, 16) == 0) { /* accept */ }

// ... and later, at the end of the function
memset(key, 0, sizeof key);

Both lines are correct in the sense that a careful reader will nod at them. memcmp compares the bytes, memset writes zeroes. What neither line is is a statement about the machine. memcmp is specified to return a sign, not to take a fixed time, and every real implementation exits early because early exit is what makes it fast. memset is a store, and in the abstract machine a store nobody can read back is unobservable, so deleting it changes nothing the standard cares about. At -O2 it commonly vanishes.

The uncomfortable part is that the compiler and the attacker want the same thing here. The compiler wants to skip work no one can observe. The attacker's whole premise is that they can observe it.

Reading every byte, and saying so#

The comparison has to read the full length, keep every branch independent of the contents, and push the accumulated difference through something the optimizer is not allowed to reason across.

cpp
#include "ctsafe/ctsafe.hpp"

// Spans: both lengths travel with the data, so the size is never retyped.
if (ctsafe::equals(expected_tag, presented_tag)) { /* accept */ }

// Or a pointer and a length, when that is what the caller has.
if (ctsafe::equals(mac, expected, 16)) { /* accept */ }

The span overload accepts anything with data() and size(), so std::array, std::vector, std::string, a C array, std::span on C++20, and that matters more than it looks. Most instances of this bug I have found were not a missing constant-time routine. They were a correct routine called with a length retyped at the call site, where it can drift away from the buffer it describes. When the length travels with the data, that drift is not expressible.

A size mismatch answers false before a byte is read. That is deliberate, and worth saying plainly: the length is not secret. Two buffers of different sizes are a structural error rather than a guess to protect, and reading the shorter one past its end to preserve symmetry would be a far worse bug than the leak you avoided.

The demo in the repository prints the property directly:

console
correct tag            accepted=1
wrong in the last byte accepted=0
wrong in the first     accepted=0
  (all three read all 16 bytes; memcmp would not)

Wrong in the first byte costs what wrong in the last byte costs. That is the whole claim.

The leak moves up one line#

Nobody warns you about this part. You replace memcmp, you feel finished, and then you write the code that uses the answer:

cpp
if (ctsafe::equals(expected_tag, presented_tag)) {
    memcpy(session_key, derived_key, 32);
}

The comparison no longer leaks. The branch around the memcpy does, the same signal one line further out. Accepted and rejected requests now take visibly different paths, with different stores and a different cache footprint. The fix is to stop treating the answer as a branch condition and start treating it as arithmetic.

cpp
ctsafe::mask accepted = ctsafe::mask_from_bool(ctsafe::equals(expected_tag, presented_tag));
ctsafe::copy_if(accepted, session_key, derived_key, 32);
ctsafe::select(accepted, out, derived_key, fallback_key, 32);

select and copy_if read both sides and write every byte of the destination whichever way the mask goes, so a rejected copy costs exactly the stores an accepted one costs. The subtle part is the mask itself, which passes through a value barrier before the loop. Without it the compiler can discover what the mask holds and helpfully rewrite the masked loop as a branch around a memcpy, putting back the leak the mask was there to remove. That recurs across this whole area: each of these routines is one inference away from being optimized back into the bug it fixes.

And a mask is 0xFF or 0x00, nothing else. These functions are arithmetic, not a test. Handed 0x01, copy_if mixes the two sides bit by bit instead of rejecting it. Masks come from eq or mask_from_bool, and a value invented elsewhere is a bug that no validation inside the loop could catch without (of course) branching on it.

Erasure is a platform routine, not a clever loop#

The right move for zeroing is not to argue with the optimizer. Every platform I ship on already provides a routine whose entire purpose is to not be elided:

Platform erase calls
Windows SecureZeroMemory
glibc >= 2.25, OpenBSD, FreeBSD explicit_bzero
Annex K available (__STDC_LIB_EXT1__) memset_s
anything else stores through a volatile pointer

The volatile row is the last resort, not the design. Every path ends in a compiler barrier, an empty asm block with a memory clobber on GCC and Clang, _ReadWriteBarrier() on MSVC, so the buffer cannot be treated as dead across the call. On Windows the header reaches SecureZeroMemory through <windows.h>, and defining CTSAFE_NO_WINDOWS_H keeps that out of the translation unit and takes the fallback instead.

cpp
ctsafe::erase_object(session);     // size taken from the type, not retyped
ctsafe::erase_backend_name();      // which routine that erase actually called

Two things there I would defend in review. erase_object takes the size from the type, which removes the retyped-length failure that the span overload removes from the comparison. And erase_backend_name exists because a guarantee you have to guess at is not one: preprocessor-selected behaviour you cannot interrogate at runtime is how a build quietly lands on the fallback path and nobody notices for two years. The test suite runs at -O2 as well as under the sanitizers, because an erasure that only survives at -O0 is the bug being tested for.

What none of this buys#

A unit test cannot prove constant time. Only reading the generated instructions can, and even then the CPU has the last word: caches, branch prediction and speculative execution sit outside the reach of a portable header. What you get is source that does not ask the compiler to leak, plus barriers against the two specific optimizations that break these routines. That is a smaller claim than "constant time", and I think it is the honest one.

Erasing a buffer also does not erase its copies. Nothing in that table reaches a register spill, a block that realloc moved, or a page the kernel already wrote to swap. Erase clears the bytes you name, at the moment you name them. Everywhere else the value travelled is a different problem, and mostly an architectural one.

What I would do differently#

Earlier I treated the comparison as the fix, and I was wrong in two directions. The branch at the call site kept the leak alive while I congratulated myself on the loop, and I verified the erasure in a debug build, where it is present anyway and therefore proves nothing. The checks I care about now: the erase backend is asserted in the test run rather than assumed, secret material lives in one type so its size comes from the type at every erase site, and the real work is reducing the number of places a key exists at all rather than trusting erase to clean up after a value that was copied four times on the way in.

Both defects survive review because the code says what the author meant. What the machine does with it is a separate document, and the only durable repair is to state the property in a form the compiler is not permitted to discard, then make the build tell you which form it chose.

react

$ new-project --brief

or email hey@polycratia.com