Secure Coding in C · intermediate · ~12 min

Constant-time byte comparison

## What you will learn - Explain what a **timing side channel** is and how an early-exit comparison like `memcmp` leaks where two secrets first differ. - Implement a **constant-time equality** check whose runtime depends only on the length `n`, never on the data. - Use the XOR-and-OR-accumulate pattern correctly: fold every byte pair so a single accumulator answers "equal or not". - Recognise and avoid the subtle mistakes that silently reintroduce the leak (early `break`, branching on a secret, signed return values). - Decide *when* a constant-time compare is required (secrets: MACs, tokens, password hashes) and when an ordinary `memcmp` is perfectly fine. - Verify your implementation defensively and know which vetted library functions to reach for in production.

Overview

Imagine a lock that, instead of just saying "wrong key," rattled a little longer each time you got one more pin correct. You could pick it by listening: try every first pin until the rattle lasts longer, lock that one in, then move to the second. You never see inside the lock — you just time it.

That is exactly the weakness in a naive secret comparison. When a server checks whether the security tag you sent matches the one it expected, the most natural code is memcmp. But memcmp stops at the first differing byte. The closer your guess is to correct, the longer the check runs before failing. An attacker who can measure that time can recover the secret one byte at a time — without ever guessing the whole thing at once.

A constant-time comparison removes that signal. It looks at every byte regardless of where the inputs differ, so the running time reveals nothing about the data. The whole defence is a tiny loop, but getting it exactly right — and understanding why each line matters — is what this lesson is about.

This builds directly on two earlier topics. From bitwise-operators you already know that a ^ b is zero only when a and b are identical bits, and that | (OR) keeps a bit set once any input sets it — those two facts are the algorithm. From pointers you know that a byte buffer is just an address plus a length, which is why our function takes const void * pointers and a size, and why bounds and NULL checks matter.

Key terms we will use throughout:

  • Side channel — information leaked by how a computation runs (time, power, cache behaviour) rather than by its declared output.
  • MAC / tag — a short value that proves a message was not tampered with; comparing tags is the classic place this bug appears.
  • Constant-time — runtime is a function of input sizes, not input values.

Why it matters

Secret comparison happens on nearly every authenticated request your software handles, and a single missing line turns it into an exploitable leak.

  • It is a real, exploited bug class. Timing attacks on tag and token comparison are not theoretical — they have appeared in web frameworks, crypto libraries, and authentication code. The fix is always the same shape, which is why every security engineer is expected to recognise it on sight.
  • The leak is invisible in normal testing. The function returns the correct answer (equal / not equal) every time. Unit tests pass. Code review misses it unless the reviewer knows the pattern. The bug only shows up when an attacker measures timing — so you must prevent it by construction, not catch it later.
  • It compounds. Recovering a secret byte-by-byte turns an impossible 256^32 brute force into roughly 256 * 32 measured attempts. The asymmetry is enormous, which is what makes the defence non-negotiable for secrets.
  • It teaches a transferable habit. "Never branch or early-exit on a secret" is a core principle of constant-time programming that also governs how you compare passwords, validate license keys, and write low-level crypto primitives.

For a learner moving into defensive security, this is one of the highest-value patterns to internalise: small, ubiquitous, easy to get wrong, and directly tied to attacker capability.

Core concepts

1. Timing side channels

Definition. A side channel is any way a computation reveals information through its physical or observable behaviour — time taken, power drawn, cache state — rather than through its intended return value.

Plain language. Two functions can return the exact same answer yet take different amounts of time depending on the secret data they handled. If an attacker can measure that time, the duration itself becomes an output you never meant to expose.

How it works internally. memcmp(a, b, n) walks the bytes and returns the moment it finds a mismatch. So if the first byte already differs, it does ~1 comparison; if the first 10 bytes match, it does ~11. The number of loop iterations — and therefore the time — encodes how many leading bytes matched.

Attacker guesses tag, server checks with memcmp:

guess: 5A | 00 00 00 ...   first byte wrong  -> fail FAST  (1 compare)
guess: A1 | 00 00 00 ...   first byte right  -> fail slower (2 compares)
                ^ time increases as the leading-match count grows

Measure time -> learn how many leading bytes are correct -> fix one byte, repeat.

When it matters / when it doesn't. It matters whenever one operand is a secret the attacker is trying to learn (MAC tags, API tokens, session ids, password-hash outputs, OTP codes). It does not matter when both operands are public, or when the result is not attacker-observable (e.g. comparing two filenames in a local tool).

Pitfall. Believing the network jitter "hides" the leak. Attackers average over many samples; statistics recover the signal from noise. Do not rely on noise as a defence.

Knowledge check (explain in your own words): Why does returning the correct equal/not-equal answer NOT make a comparison safe for secrets?

2. The constant-time property

Definition. Code is constant-time when its execution time depends only on the sizes of its inputs, never on the values.

Plain language. Same length in, same amount of work out — whether the data matches on byte 0, byte 1000, or never.

Structure of the defence. Touch every byte unconditionally, combine the per-byte results without any data-dependent branch, and produce one final verdict at the end.

Constant-time equality of a[0..n) and b[0..n):

   acc = 0
   for i in 0..n:                # loop count fixed by n, not by data
       diff_i = a[i] XOR b[i]     # 0 iff bytes equal
       acc    = acc OR diff_i     # sticky: stays non-zero once any diff seen
   equal = (acc == 0)            # single verdict, computed after the loop

   time spent  ~  n  (always)   <- independent of WHERE a and b differ

When to use / not use. Use it for any equality check on a secret. Do not use it as a general-purpose memcmp replacement where ordering (</>) is needed — it only answers equal/not-equal, and it is intentionally slower for matching inputs.

Pitfall. Adding any if that depends on the running result ("stop early if we already differ") destroys the property. The loop must always run to n.

Knowledge check (predict the output): If n == 8 and the buffers differ only at index 7, how many loop iterations run in the constant-time version? In memcmp?

3. XOR and OR as the engine

Definition. ^ (XOR) yields a zero byte exactly when two bytes are equal; | (OR) is a sticky accumulator — once a bit becomes 1 it never goes back to 0 by OR-ing more values.

Plain language. XOR asks "are these two bytes the same?" (answer 0 = yes). OR remembers "has any pair ever said no?".

How it works internally. After the loop, acc holds the OR of all per-byte differences. If even one byte pair differed, at least one bit in acc is set, so acc != 0. If every pair matched, every diff_i was 0x00, so acc stays 0x00.

 a:  6F 6B 21        b:  6F 6B 21        ("ok!" vs "ok!")
 ^:  00 00 00   ->  acc = 00 | 00 | 00 = 00   -> EQUAL

 a:  6F 6B 21        b:  6F 6B 3F
 ^:  00 00 1E   ->  acc = 00 | 00 | 1E = 1E   -> NOT EQUAL (and 1E != 0)

When to use / not use. This is the right engine for fixed-length secret comparison. It is not suitable when the two inputs have different lengths you must hide — leaking length is a separate concern handled by hashing both sides first.

Pitfall. Using + instead of | to accumulate. Addition can overflow and, in pathological cases, wrap back toward patterns that obscure a difference; OR is safe because difference bits are sticky. (XOR-accumulate also works; OR is the conventional, clearest choice.)

Knowledge check (find the bug): A learner writes acc &= a[i] ^ b[i]; using AND instead of OR. Why does this give the wrong answer for most inputs?

Syntax notes

The function signature mirrors memcmp so it is a drop-in for secret comparison, but it returns a simple equal/not-equal verdict.

#include <stddef.h>   /* size_t */

/* Returns 0 if the first n bytes of a and b are all equal, non-zero otherwise.
   Runtime depends only on n, not on the contents. */
int ct_memcmp(const void *a, const void *b, size_t n);

Key syntax points:

  • const void * — accepts any byte buffer; cast to const unsigned char * inside before indexing (you cannot index a void *).
  • size_t n — unsigned length type, the natural type for buffer sizes.
  • The accumulator must be an unsigned char so XOR/OR stay defined and the high bit cannot be misread as a sign.
  • The loop condition i < n is the only loop control — there is no break.

Annotated core:

const unsigned char *pa = a, *pb = b;  /* index bytes through unsigned char */
unsigned char acc = 0;                 /* sticky difference accumulator */
for (size_t i = 0; i < n; i++)
    acc |= (unsigned char)(pa[i] ^ pb[i]); /* no data-dependent branch here */
return acc;                            /* 0 == equal; promoted to int safely */

Lesson

Why this matters

A MAC tag (Message Authentication Code) proves that a message has not been tampered with. To check it, you compare the received tag against the expected one.

If you compare them with memcmp, you create a problem. memcmp stops at the first byte that differs. So the time it takes to fail depends on how many bytes matched before the mismatch.

That timing difference is a side channel: an attacker can measure it and learn how far their guess got. By repeating this, they can recover the correct tag one byte at a time.

The fix is a constant-time compare: a comparison that always touches every byte, so the runtime does not reveal where the inputs first differed.

This is one of the most-cited defensive patterns in cryptography. The core of it is three lines.

The shape

unsigned char acc = 0;
for (size_t i = 0; i < n; i++) acc |= a[i] ^ b[i];
return acc;  /* zero iff a and b are equal in all n bytes */

How it works:

  • For each byte pair, a[i] ^ b[i] is 0 only if the two bytes are equal.
  • OR-ing those results into acc keeps acc at 0 only if every pair was equal.
  • The loop runs the full length every time, whether the inputs match early, late, or not at all.

Your job

Implement:

int ct_memcmp(const void *a, const void *b, size_t n);

Rules:

  • Return 0 if all n bytes match.
  • Return non-zero otherwise.
  • The runtime must be constant (independent of where the bytes differ).
  • If a or b is NULL while n > 0, return 1.

Common mistakes

  • Adding an if (acc != 0) break; "optimisation". That early exit is exactly the timing leak you were trying to avoid.
  • Returning the accumulator as a signed value. Cast it to unsigned char first.

What this is NOT

  • It is not a faster memcmp. For matching inputs it is strictly slower, and that is the point.
  • It is not a production crypto wrapper. Libsodium and OpenSSL ship their own vetted versions. This exercise is to internalise the shape.

Code examples

#include <stdio.h> #include <stddef.h>

/*

  • Constant-time byte equality.

  • Returns 0 if the first n bytes of a and b are all equal, non-zero otherwise.

  • The number of loop iterations is fixed by n, so the runtime does not reveal

  • WHERE (or whether) the buffers differ. This is the defence used when one of

  • the operands is a secret such as a MAC tag or an authentication token.

  • Defensive contract: if a or b is NULL while n > 0, treat as "not equal"

  • (return non-zero) rather than dereferencing a NULL pointer. */ int ct_memcmp(const void *a, const void b, size_t n) { if (n == 0) return 0; / nothing to compare -> equal by definition / if (a == NULL || b == NULL) return 1; / cannot compare; report not-equal, no deref */

    const unsigned char pa = a; / void* cannot be indexed; view as bytes */ const unsigned char pb = b; unsigned char acc = 0; / stays 0 only if every byte pair matches */

    for (size_t i = 0; i < n; i++) acc |= (unsigned char)(pa[i] ^ pb[i]); /* sticky OR; NO early exit */

    return acc; /* 0 == all bytes equal; non-zero otherwise */ }

static void report(const char *label, int r) { printf("%-28s -> %s (ret=%d)\n", label, r == 0 ? "EQUAL" : "NOT EQUAL", r); }

int main(void) { unsigned char expected[] = { 0x9F, 0x12, 0xAB, 0x40, 0x01 }; unsigned char same[] = { 0x9F, 0x12, 0xAB, 0x40, 0x01 }; unsigned char diff_last[]= { 0x9F, 0x12, 0xAB, 0x40, 0xFF }; unsigned char diff_first[]={ 0x00, 0x12, 0xAB, 0x40, 0x01 };

size_t n = sizeof expected;

report("identical tags",      ct_memcmp(expected, same,      n));
report("differs at last byte", ct_memcmp(expected, diff_last, n));
report("differs at first byte",ct_memcmp(expected, diff_first,n));
report("zero length",          ct_memcmp(expected, same,      0));
report("NULL pointer guard",   ct_memcmp(expected, NULL,      n));

return 0;

}

Line by line

Walkthrough of the key call ct_memcmp(expected, diff_last, 5), where the buffers match on bytes 0–3 and differ at byte 4.

  1. if (n == 0)n is 5, so we skip this; there is real work to do.
  2. if (a == NULL || b == NULL) — both pointers are valid, so we continue. (This guard exists so a buggy caller can't crash us.)
  3. pa and pb are set to view the buffers as unsigned char arrays so we can index byte-by-byte. Indexing a raw void * is illegal in C; this cast is what makes byte access well-defined.
  4. acc = 0 — the accumulator starts clean. It will stay 0 only if no byte pair differs.
  5. The loop runs i = 0,1,2,3,4 — always five iterations, set by n alone.
  6. return acc — after the loop, acc is 0xFE (non-zero), so the caller sees "not equal".

Trace of acc through the loop:

 i | pa[i] | pb[i] | pa^pb | acc (after OR)
---+-------+-------+-------+---------------
 0 |  9F   |  9F   |  00   |  00
 1 |  12   |  12   |  00   |  00
 2 |  AB   |  AB   |  00   |  00
 3 |  40   |  40   |  00   |  00
 4 |  01   |  FF   |  FE   |  FE   <- first/only difference; loop still finishes

The decisive insight: had the difference been at i = 0 instead, acc would become non-zero on the first iteration — but the loop would still execute all five iterations. Same iteration count, same time, no leak. Contrast memcmp, which would return at i = 0 and finish far sooner, leaking that the first byte was wrong.

Common mistakes

Mistake 1 — Adding an early exit "for speed"

/* WRONG: reintroduces the exact timing leak */
for (size_t i = 0; i < n; i++) {
    if (pa[i] != pb[i])
        return 1;            /* exits early -> time depends on data */
}
return 0;

Why it is wrong: this is just memcmp again. The number of iterations now depends on where the first difference is, so timing reveals the leading-match count.

Corrected: keep the unconditional fold and the single post-loop verdict.

unsigned char acc = 0;
for (size_t i = 0; i < n; i++) acc |= (unsigned char)(pa[i] ^ pb[i]);
return acc;

How to recognise it: any return, break, or continue inside the loop that depends on the bytes is a red flag.

Mistake 2 — Branching on the secret outside the loop

/* WRONG: the branch's outcome is itself data-dependent */
return (acc != 0) ? 1 : 0;   /* often fine, but... */
if (acc) log("mismatch at hidden position"); /* DON'T act differently on secret */

A plain acc != 0 verdict is acceptable because it runs after the full loop and reveals only the final answer (which the caller is allowed to know). The danger is doing more secret-dependent work afterwards. Keep post-comparison work uniform.

Mistake 3 — Signed accumulator / signed char

char acc = 0;                 /* WRONG on platforms where char is signed */
acc |= a[i] ^ b[i];           /* high-bit values may sign-extend / surprise */

Why it is wrong: a difference byte like 0x80 can be interpreted as negative, and sign-extension during integer promotion can produce confusing values. Corrected: always use unsigned char for the accumulator and for byte access.

Mistake 4 — Dereferencing before the NULL/length guard

const unsigned char *pa = a;
if (a == NULL) return 1;      /* WRONG order is harmless here, but... */
acc |= pa[0] ^ pb[0];         /* ...indexing before checking n>0 reads OOB if n==0 */

Corrected: check n == 0 and NULL before any indexing, as in the lesson code.

Debugging tips

Compiler stage

  • error: invalid use of void expression or arithmetic on a pointer to void — you indexed a[i] directly. Cast to const unsigned char * first.
  • Warnings under -Wall -Wextra about comparison of integers of different signs usually point at mixing int and size_t; make the loop counter size_t.
  • Build with gcc -std=c11 -Wall -Wextra -O2 file.c (or clang). Warnings here are cheap bugs caught early.

Runtime stage

  • A crash (segfault) almost always means you indexed a NULL or too-short buffer. Run under valgrind ./a.out or build with -fsanitize=address,undefined to get the exact offending access.
  • If n is larger than either buffer, you read out of bounds even though the logic is "correct". Confirm the caller passes the true length.

Logic stage

  • Function always returns 0 (claims everything equal): you probably used &= instead of |=, or initialised acc to a non-zero value, or never assigned the XOR into acc.
  • Function returns non-zero for identical inputs: check for an off-by-one in the loop bound or a stray + 1 on a pointer.
  • Timing still leaks (advanced): inspect the loop for any compiler-introduced branch; on hot paths people use the library functions below precisely because compilers may "optimise" naive code.

Questions to ask when it doesn't work

  • Did I check n == 0 and NULL before touching memory?
  • Is the accumulator unsigned char and the counter size_t?
  • Is there any if/break/return inside the loop?
  • Are both buffers really at least n bytes long?

Memory safety

Security & safety

Authorisation & ethics. Everything here is defensive. Practise only on your own code, in a local or containerised lab. Do not use timing measurements against systems you are not explicitly authorised to test. The goal is to write comparisons that cannot be attacked, not to attack anyone.

Threat model for tag comparison

Asset:        the secret tag / token the server holds (must stay unknown)
Entry point:  attacker-controlled bytes submitted for comparison
Trust bdry:   [ network ] --untrusted guess--> [ server compares vs secret ]
Leak vector:  response TIME (a side channel), not the response body
Goal:         attacker reconstructs the secret one byte at a time via timing
Defence:      constant-time compare -> time carries no info about the secret

Memory-safety concerns specific to this code

  • Bounds. The function trusts n. If n exceeds the real length of either buffer, the loop reads out of bounds (undefined behaviour, possible crash or info leak). The caller must pass the correct length; document this.
  • NULL / lifetimes. Guard NULL before dereferencing (done in the example). Never compare a pointer to memory that has been freed — a dangling read is UB regardless of timing.
  • Initialisation. acc must start at 0. An uninitialised accumulator is UB and can yield wrong verdicts.
  • Type / overflow. Use unsigned char (OR/XOR are well-defined, no sign surprises) and size_t for the counter to avoid signed-overflow UB and signed/unsigned comparison bugs.

Defensive practices

  • Prefer a vetted primitive in production: crypto_verify_* / sodium_memcmp (libsodium), CRYPTO_memcmp (OpenSSL), or timingsafe_bcmp (BSD). Write your own only to learn the pattern.
  • Compare fixed-length values. If lengths can differ and the length itself is secret, hash both sides to a fixed size first, then constant-time compare the hashes.

Logging guidance. Log the event ("tag verification failed for request X"), never the secret, the attacker's guess, or the position of the mismatch. Logging "mismatch at byte 4" would hand the attacker exactly the side-channel data you removed.

Real-world uses

Where this runs in real systems

  • API authentication. Verifying HMAC signatures on webhook payloads (e.g. payment or CI providers) — the receiver recomputes the HMAC and constant-time-compares it to the header value.
  • Session & CSRF tokens. Comparing a submitted token to the stored one on every request.
  • Password verification. Comparing the output of a slow password hash (Argon2/bcrypt) against the stored hash; the comparison of the final digests should be constant-time.
  • Crypto libraries & TLS. MAC verification inside record processing; this is where real-world timing bugs have caused CVEs.
  • Embedded / firmware. Bootloader signature and update-token checks, where timing is even easier for an attacker with physical access to measure.

Professional best-practice habits

Beginner rules

  • If either side of an equality check is a secret, use a constant-time compare — make this reflexive.
  • Never break/return inside the comparison loop.
  • Validate lengths and NULLs before touching memory; clean up any temporary buffers (and zero them if they held secrets).

Advanced rules

  • In production, call a vetted library primitive (libsodium / OpenSSL) instead of hand-rolling; compilers may transform naive loops in ways that reintroduce branches.
  • Compare fixed-length digests; hash variable-length inputs first so you never leak length.
  • After handling secrets in temporary buffers, scrub them (explicit_bzero / sodium_memzero) so they don't linger in memory.
  • Add a regression test that asserts the function never early-exits (e.g. a code-review checklist item), since the bug is invisible to functional tests.

Practice tasks

Beginner 1 — Implement the core

Objective. Write int ct_memcmp(const void *a, const void *b, size_t n) exactly as specified in this lesson.

Requirements. Return 0 when all n bytes match, non-zero otherwise; runtime must depend only on n. Handle n == 0 (equal) and NULL with n > 0 (not equal) without dereferencing.

Example. ct_memcmp("abc", "abc", 3) == 0; ct_memcmp("abc", "abd", 3) != 0.

Constraints. No break/return/continue inside the loop. Accumulator is unsigned char; counter is size_t.

Hints. Cast void * to const unsigned char *; fold with |= (unsigned char)(pa[i] ^ pb[i]).

Concepts: XOR/OR engine, constant-time property.

Beginner 2 — Spot the leak

Objective. Given three candidate comparison functions, write int is_timing_safe(int which) that returns 1 for the safe one and 0 otherwise, then explain in a comment why each unsafe one leaks.

Requirements. One candidate uses memcmp; one uses a loop with if (a[i]!=b[i]) return 1;; one uses the XOR/OR fold. Identify the safe one.

Hints. Look for any data-dependent early exit.

Concepts: timing side channels, recognising early exits.

Intermediate 1 — Verdict wrapper

Objective. Build int ct_equal(const unsigned char *a, const unsigned char *b, size_t n) that returns 1 for equal, 0 for not equal (note: inverted from ct_memcmp), still in constant time.

Requirements. Derive the boolean from acc without a data-dependent branch on the secret bytes themselves. The final acc != 0 verdict (computed once, after the loop) is allowed.

Input/Output. ct_equal((u8*)"key",(u8*)"key",3) == 1; differing input returns 0.

Hints. A common branch-free trick maps non-zero acc to 1 using bit operations; or simply return acc == 0; after the full loop, which is fine because it runs once at the end.

Concepts: constant-time verdict, avoiding mid-loop branching.

Intermediate 2 — Hash-then-compare for variable length

Objective. Write int token_matches(const char *submitted, const char *stored) for NUL-terminated tokens of possibly different lengths, without leaking the length difference.

Requirements. Conceptually: reduce both inputs to a fixed-size value first (e.g. a fixed-length digest stub you define for the lab), then constant-time compare the fixed-size values. Do not branch early on strlen differences in a way that leaks.

Hints. If you compared raw strings of different lengths directly, where would the length leak? Fixing both to the same length before comparing removes it.

Concepts: length leakage, fixed-length compare, threat modelling.

Challenge — Demonstrate and then close the leak (lab only)

Objective. In a local, isolated program, write a deliberately leaky bad_compare (early-exit) and a ct_memcmp, time many runs of each against inputs that match 0, 1, 2, … leading bytes, and chart how bad_compare's time rises with leading-match count while ct_memcmp's stays flat.

Requirements. Run thousands of iterations and average to beat noise. Label the leaky function with the required warning. Compare the two timing curves and write up what an attacker could infer from each.

Constraints. WARNING: Intentionally vulnerable training example — use only in a local, isolated, authorized lab. Do not deploy. Never time a system you do not own.

Hints. Use a monotonic clock (clock_gettime(CLOCK_MONOTONIC, …)); warm up before measuring; the shape of the curve matters more than absolute numbers.

Concepts: side-channel measurement, mitigation verification, ethics.

Summary

  • The problem. Comparing secret data (MAC tags, tokens, password-hash digests) with memcmp leaks timing, because memcmp exits at the first differing byte. An attacker measures the time and recovers the secret one byte at a time.
  • The defence. A constant-time compare touches every byte regardless of the data, so runtime depends only on the length n — never on where the inputs differ.
  • The pattern (memorise this shape). acc = 0; then for (i<n) acc |= (unsigned char)(a[i] ^ b[i]); then return acc; (0 means equal). XOR detects per-byte differences; OR makes them sticky; the loop never early-exits.
  • Most important syntax. const void * parameters cast to const unsigned char *; size_t counter; unsigned char accumulator; no break/return inside the loop; NULL and n == 0 guarded before any dereference.
  • Common mistakes. Adding an early exit "for speed" (the leak returns), using a signed/char accumulator, using &= instead of |=, leaking the mismatch position in logs, and comparing different lengths so the length itself leaks.
  • Remember. Never branch or early-exit on a secret. In production, prefer vetted primitives (sodium_memcmp, CRYPTO_memcmp, timingsafe_bcmp); hash variable-length inputs to a fixed length before comparing; and log the failure event, never the secret or the mismatch position.

Practice with these exercises