In Ruby's openssl library, how do OpenSSL.fixed_length_secure_compare and OpenSSL.secure_compare differ, and when would you pick each?
answer
- CRYPTO_memcmp under the hood
- unequal lengths raise ArgumentError
- SHA-256 both inputs first
- masks the secret's length
- String#== exits early
basics
~10 sOpenSSL.fixed_length_secure_compare compares equal-length strings in constant time and raises ArgumentError when lengths differ. OpenSSL.secure_compare hashes both inputs with SHA-256 first, so any lengths work and the length is masked, at extra cost.
solid answer
~40 s`OpenSSL.fixed_length_secure_compare(a, b)` wraps OpenSSL's `CRYPTO_memcmp`: for strings of equal length it takes the same time wherever they differ and returns `true` or `false`, but for different lengths it raises `ArgumentError`. That makes it right when both values are known to have the same length, such as two raw HMAC digests you computed or decoded. `OpenSSL.secure_compare(a, b)` hashes both inputs with SHA-256, compares the two 32-byte digests with `fixed_length_secure_compare`, and only if they match confirms with `a == b`. It never raises on a length mismatch and hides the secret's length, so it is the safe default when one side comes from a request. `String#==` checks length and then uses `memcmp`, whose time can depend on where the first difference is.
code
ruby · 7 linesrequire "openssl"
OpenSSL.fixed_length_secure_compare("abc", "abd") # => false
OpenSSL.fixed_length_secure_compare("abc", "abcd") # ArgumentError: inputs must be of equal length
OpenSSL.secure_compare("abc", "abcd") # => false
OpenSSL.secure_compare("abc", "abc") # => truego deeper
Remember that signatures and tokens are compared with OpenSSL.secure_compare rather than ==, because == can stop early.
Explain that fixed_length_secure_compare raises ArgumentError on different lengths while secure_compare hashes both inputs first and returns false.
Choose the helper per call site, return one response for every failure, and normalise encodings before comparing received values.
Make constant-time comparison the only sanctioned way to check secrets, with a shared helper and a review rule for ad-hoc equality.
## Why Ruby ships two comparisons Comparing a secret value, such as an HMAC signature or an API token, with `==` can leak information through **timing**: an ordinary comparison may stop at the first differing byte, so an attacker who can measure response times might learn how many leading bytes they guessed correctly. The `openssl` default gem provides two constant-time alternatives, and they differ in one decision: what to do when the lengths differ. | | `fixed_length_secure_compare(a, b)` | `secure_compare(a, b)` | |---|---|---| | Implemented in | C, calling OpenSSL's `CRYPTO_memcmp` | Ruby, in `lib/openssl.rb` | | Equal lengths | constant-time, `true` or `false` | hashes both, compares digests, `true` or `false` | | Different lengths | raises `ArgumentError` | returns `false` | | Reveals length? | only through the exception path | no, digests are always 32 bytes | | Cost | one pass over the bytes | two SHA-256 computations plus a comparison | ## How secure_compare works The method body is three lines: 1. `OpenSSL::Digest.digest("SHA256", a)` and the same for `b` produce two 32-byte strings, whatever the input lengths. 2. `OpenSSL.fixed_length_secure_compare` compares those digests in constant time. 3. Only if the digests match does it evaluate `a == b`, a final guard that confirms the originals really are equal. Because the timing-sensitive step always runs on 32-byte values, an attacker learns nothing about the secret's length or content from how long a mismatch takes. The final `==` only runs when the inputs already agree, so it leaks nothing an attacker does not already have. ## When to pick each - **Pick `secure_compare`** when either side comes from outside: a signature header, a token in a query string, an API key in a request. Its length is not under your control, and a raised `ArgumentError` would turn a bad request into a 500 or reveal the expected length. - **Pick `fixed_length_secure_compare`** when both values are known to have the same length because you produced them: two raw HMAC digests, or a received value you already length-checked and decoded. It is cheaper, and the documentation recommends it for that case. - **`OpenSSL::HMAC#==`** does this for you when you hold two `OpenSSL::HMAC` objects: it checks the other is an HMAC with the same digest size, then calls `fixed_length_secure_compare` on the digests. - **`BCrypt::Password#==`** is a separate mechanism: it rehashes the candidate and compares in constant time itself, so a password check needs no extra helper. ## What goes wrong with == - `String#==` returns `false` immediately when lengths differ, then compares bytes with `memcmp`, whose running time can depend on the position of the first difference. - `eql?` and `casecmp?` are content comparisons too; none of them promise constant time. - A common half-fix, `a.bytesize == b.bytesize && a == b`, can still exit early inside the second comparison. ## Practical caveats - **Compare the same encoding.** Constant-time comparison of hex against Base64 is constantly `false`. Normalise first (for example `downcase` a hex header), and do that normalisation on the received value only. - **Timing is one leak among several.** Different error messages or status codes for "wrong length" and "wrong value" leak the same information more cheaply; return one response for every failure. - **Network noise does not make `==` safe.** Measuring many requests averages noise away, which is why libraries default to constant-time helpers rather than arguing about exploitability case by case. ## A verification helper, step by step 1. **Reject obviously malformed input early and uniformly**: a missing header returns the same failure as a wrong one. 2. **Normalise the received value** into the encoding you compute: strip a `sha256=` prefix, `downcase` hex. 3. **Compute the expected value** yourself, never from anything the request supplied except the signed data. 4. **Compare with `OpenSSL.secure_compare(expected, received)`**, or decode both to raw bytes, check the length, and use `fixed_length_secure_compare` if the extra hashing matters at your volume. 5. **Return one response** for every failure path, and log the reason server-side only. Wrapping these steps in one method, used by every endpoint, is what keeps a later handler from quietly reintroducing `==`.
- Why does OpenSSL.secure_compare end with a == b after the constant-time digest comparison?It is a final confirmation that the original strings, not just their SHA-256 digests, are equal. It runs only when the digests already matched, so the inputs are almost certainly equal and the early-exit timing of `==` reveals nothing an attacker could use.
- When is fixed_length_secure_compare with a raised ArgumentError actually a problem?When one input comes from a request. A malformed or truncated signature then raises instead of returning false, which can surface as a 500 error or a distinct response that reveals the expected length. Check the length yourself first, or use `secure_compare`.
fixed_length_secure_compare is a guard who checks every digit of a combination before answering, but refuses to start if you hand over a code with the wrong number of digits; secure_compare first copies both codes onto identical-sized cards, so every attempt gets checked the same way.
saying these in an interview costs you the question
- fixed_length_secure_compare returns false when the lengths differ.
- secure_compare is just an alias for fixed_length_secure_compare.
- Checking bytesize first makes == a constant-time comparison.
- Constant-time comparison is only needed for passwords, not HMAC signatures.