Attesto.SecureCompare (Attesto v1.7.0)

Copy Markdown View Source

Result-independent comparison of two binaries.

Used wherever an attacker-controlled value is checked against a secret or a derived digest (a DPoP ath, a PKCE challenge) and a short-circuiting == would leak information through timing.

"Result-independent" rather than "constant-time": how long equal?/2 takes does not depend on HOW the operands differ - whether the length was wrong, or a prefix matched - but it does depend on their total size, since both are hashed. See equal?/2 for what that does and does not withhold.

Summary

Functions

Returns true iff a and b are byte-identical, without the comparison revealing whether they were the same length.

Functions

equal?(a, b)

@spec equal?(binary(), binary()) :: boolean()

Returns true iff a and b are byte-identical, without the comparison revealing whether they were the same length.

:crypto.hash_equals/2 requires equal-length inputs, so a length guard is unavoidable somewhere. Guarding with byte_size(a) == byte_size(b) before it short-circuits, and the time to answer then separates "wrong length" from "right length, wrong bytes" - a distinction that tells an attacker probing a secret when to stop varying length and start varying content. Hashing both operands to 32 bytes removes it: the comparison is over equal lengths by construction and takes the same path either way.

What this does not do

It is not constant-time in the total size of its inputs. SHA-256 reads every byte, so a call with two 100 KB operands takes materially longer than one with two 1-byte operands. What is withheld is the result-dependent signal - whether a guess was the right length, or matched to some prefix - not the fact that a large value was compared. A caller who needs the size itself hidden must pad or cap before calling.

Callers should still validate shape first where they can, and most here do: Attesto.PKCE.verify/3 gates on Attesto.Thumbprint.valid?/1, which requires an exact byte size, so its operands are fixed-length before this is reached. Attesto.DPoP's ath comparison does not - the presented value is an arbitrary-length claim from the proof - which is precisely the case that benefits.

The final byte comparison runs only when the digests already match, which IS a result-dependent branch - an equal pair does that extra work and an unequal pair does not. It is deliberate and it leaks nothing useful: reaching it means the caller already supplied the correct value (or a SHA-256 collision), so the timing difference separates "right" from "wrong", which the answer itself already reveals. What is withheld is the distinction among WRONG inputs - the one an attacker probes with.