Blog / Go

  • go
  • crypto-subtle
  • constant-time
  • timing-attack
  • security
  • cryptography

Go's ConstantTimeCompare Returns Early on Length Mismatch

Everyone who has read one article about timing attacks knows the rule: do not compare secrets with ==, use subtle.ConstantTimeCompare. Fair enough. But the name promises slightly more than the function delivers, and the gap is in the doc comment, which is short enough that people skim it. The relevant sentence: if the lengths of the two slices do not match, it returns 0 immediately.

The implementation makes this obvious once you look at it. Paraphrased, it is roughly:

func ConstantTimeCompare(x, y []byte) int {
	if len(x) != len(y) {
		return 0
	}

	var v byte
	for i := 0; i < len(x); i++ {
		v |= x[i] ^ y[i]
	}

	return ConstantTimeByteEq(v, 0)
}

The loop touches every byte regardless of where the first mismatch is, which is the whole point. The early return sits above it. So the running time depends on whether the lengths match, and when they do, on that length.

What an attacker actually learns

Say you check an API token like this:

func checkToken(got, want []byte) bool {
	return subtle.ConstantTimeCompare(got, want) == 1
}

An attacker submits guesses of length 1, 2, 3 and so on. Wrong lengths return almost instantly. The correct length runs the loop over n bytes, which costs a little more. With enough samples and a quiet enough path, the slow bucket is the secret's length. That is all they get: not the contents, just how long it is.

Is that a real problem? Often, no, and it is worth being honest about that. Lots of secrets have a public, fixed length: a 32-byte token hex-encoded to 64 characters, a 20-byte HMAC-SHA1 tag, a 32-byte SHA-256 MAC. If the length is already known, leaking it costs nothing. The difference is also tiny: a 32-byte loop is tens of nanoseconds, which is buried under network jitter, though remote timing attacks have been demonstrated against surprisingly small differences given enough samples, and local attackers (another container on the same host, say) have a much cleaner signal.

It matters when the length carries information. Passwords stored as raw values (please don't, but it happens), variable-length bearer tokens where the length encodes a format or tenant, or PINs where "it is 6 digits, not 4" narrows a search space. There, the early return is a small oracle you have handed out for free.

The fix: make the lengths equal before comparing

The usual answer is to hash both sides first. A SHA-256 digest is always 32 bytes, so the length check inside ConstantTimeCompare can never fail on the attacker-controlled side, and the comparison time no longer depends on the secret's length.

package main

import (
	"crypto/sha256"
	"crypto/subtle"
)

// wantSum is computed once, at startup, from the real secret.
var wantSum = sha256.Sum256([]byte("correct horse battery staple"))

func checkSecret(got string) bool {
	gotSum := sha256.Sum256([]byte(got))
	return subtle.ConstantTimeCompare(gotSum[:], wantSum[:]) == 1
}

Actually, this bit is interesting: hashing the attacker's input takes time proportional to the attacker's input length, not the secret's. So the only thing that varies is something the attacker already controls. Nothing about the secret leaks through it.

Precomputing the digest of the stored secret also means you are not hashing it on every request. If the secret is a real password, none of this replaces a proper password hash (bcrypt, argon2id); this is for comparing high-entropy secrets like API tokens and MACs.

The HMAC variant

Some people prefer keyed hashing, on the theory that the digest itself then reveals nothing even if an attacker could somehow observe it. With a random per-process key it looks like this:

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
)

var key = rand.Text() // random per-process key, discarded on restart

func digest(s string) []byte {
	m := hmac.New(sha256.New, []byte(key))
	m.Write([]byte(s))
	return m.Sum(nil)
}

func checkSecret(got, want string) bool {
	return hmac.Equal(digest(got), digest(want))
}

Note that hmac.Equal is just a wrapper around subtle.ConstantTimeCompare, so it has exactly the same length behaviour. It is safe here only because both inputs are already 32 bytes. Passing raw variable-length values to hmac.Equal has the same early return. The plain SHA-256 version is simpler and, for this purpose, does the same job.

Checking the claim yourself

A benchmark shows the shape without any drama. Compare a 32-byte secret against guesses of 31 and 32 bytes:

package main

import (
	"crypto/subtle"
	"testing"
)

var secret = make([]byte, 32)

func BenchmarkWrongLength(b *testing.B) {
	guess := make([]byte, 31)
	for i := 0; i < b.N; i++ {
		subtle.ConstantTimeCompare(guess, secret)
	}
}

func BenchmarkRightLength(b *testing.B) {
	guess := make([]byte, 32)
	guess[0] = 1 // wrong content, right length
	for i := 0; i < b.N; i++ {
		subtle.ConstantTimeCompare(guess, secret)
	}
}

Run go test -bench . and the right-length case will be visibly slower than the wrong-length one. Exact numbers depend on your CPU and Go version, so I will not quote any. Now run the same pair through the hash-first wrapper and the gap disappears into the cost of SHA-256, which is the same either way.

Where this leaves you

ConstantTimeCompare is constant time with respect to the contents of equal-length inputs. That is a narrower promise than "constant time", and its authors documented it precisely. If your secrets have a fixed public length, use it directly and move on. If the length is itself worth protecting, hash first, and keep the comparison on fixed-size digests.