Skip to content
1Claw Academy
Curriculum/Security Foundations2 minBeginner · Lesson 9 of 19

Lab: leak a secret with a stopwatch

Lab

Measure a real side channel. Compare a token the obvious way, watch the clock reveal how much of it you guessed right, then fix it in one line.

Every lesson so far has treated a comparison as free. It is not. A comparison that stops at the first difference takes longer the more of the secret you already have, and that difference is measurable, which turns a token check into an oracle.

  1. 1

    Write the measurement. The naive comparison is the one people write by hand; compare_digest is the standard-library alternative.

    bash
    cat > /tmp/timing.py <<'EOF'
    import hmac, statistics, time
    
    SECRET = b"s3cr3t-api-token-9f2a"
    
    def naive_equals(a, b):
        if len(a) != len(b):
            return False
        for x, y in zip(a, b):
            if x != y:
                return False        # returns as soon as it finds a difference
        return True
    
    def timed(fn, guess, rounds=20000):
        samples = []
        for _ in range(7):
            t = time.perf_counter_ns()
            for _ in range(rounds):
                fn(SECRET, guess)
            samples.append((time.perf_counter_ns() - t) / rounds)
        return statistics.median(samples)
    
    print(f"{'correct prefix':<20}{'naive (ns)':>12}{'compare_digest':>16}")
    for n in (0, 5, 10, 15, 20):
        guess = SECRET[:n] + b"x" * (len(SECRET) - n)
        print(f"{n:>2} bytes{'':<12}{timed(naive_equals, guess):>12.1f}"
              f"{timed(hmac.compare_digest, guess):>16.1f}")
    EOF
    python3 /tmp/timing.py
  2. 2

    You should see something close to this. The exact numbers vary by machine; the shape does not.

    text
    correct prefix        naive (ns)  compare_digest
     0 bytes                   191.3            39.8
     5 bytes                   302.6            40.1
    10 bytes                   394.6            39.8
    15 bytes                   489.6            39.8
    20 bytes                   571.2            39.9

Read the naive column. It climbs steadily with the number of correct bytes, because the loop runs one iteration further before it can return. An attacker who can time your endpoint does not need to guess the whole token: they guess one byte at a time, keep whichever guess was slowest, and recover the secret in linear rather than exponential attempts.

  • A 20-byte token has 256^20 possible values, which is unguessable.
  • Guessed one byte at a time it is 20 x 256 attempts at worst, which is trivial.
  • The comparison, not the token, is what collapsed the search space.
Tip

compare_digest stays flat because it always inspects every byte, and combines the results, before returning. Constant time here means independent of the data, not fast.

Watch out

Use a constant-time comparison for anything an attacker can supply and retry: API keys, session tokens, HMAC signatures, password hashes, TOTP codes. Python has hmac.compare_digest, Node has crypto.timingSafeEqual, Go has subtle.ConstantTimeCompare.

This is the first lesson in the track where the cryptography was flawless and the system leaked anyway. The key was strong, the token was random, and an ordinary equals sign gave it away. Most real breaks look like this rather than like broken maths.

Check your understanding

3 questions
1

Why does the naive comparison get slower as more of the guess is correct?

2

What does an attacker gain from the timing difference?

3

What makes compare_digest constant time?