security: fix bearer-token length oracle in constant_time_eq (#162)#164
Merged
Conversation
…_eq (#162) The previous hand-rolled constant_time_eq in crates/forkd-controller/src/auth.rs was advertised as constant-time but leaked the presented token's length via two distinct paths: 1. The length-mismatch branch's "fake work" loop used `x.wrapping_mul(0)` — the compiler is allowed to (and LLVM does) delete the loop as dead code, so response time was monotonic in the longer slice's length. 2. Even if the loop had been preserved, taking different branches for length-equal vs length-mismatch was itself a timing oracle. For a deployment with a fixed-length token (e.g. 32-byte hex from `forkd doctor`), the length oracle collapses security to a single attempted length, after which the equal-length branch is real constant-time. Fix: replace with `subtle::ConstantTimeEq` against a zero-padded copy of the presented token. Same code path regardless of input length; length difference is folded in via a non-short-circuiting bitwise AND so the function's total time doesn't depend on the byte-comparison's result either. `subtle` was already an indirect dep via the rustls / aws-lc-rs subtree, so the patch is lockfile- neutral. Adds three regression tests: - presented being a strict prefix of expected → reject - presented longer than expected → reject (padded view truncates, so an attacker can't bypass by appending) - zero-tail accident: presented shorter than expected, where expected's tail happens to be 0x00 → still reject cargo test -p forkd-controller --lib auth:: → 5 passed. Thanks to @m-dmupba for the precise report. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #162.
The hand-rolled
constant_time_eqincrates/forkd-controller/src/auth.rswas advertised as constant-time but leaked the presented token's length via two distinct paths, both reported precisely by @m-dmupba:Dead-code optimization: the length-mismatch branch's "fake work" loop used
x.wrapping_mul(0) == 0, which the compiler is allowed to (and LLVM does) eliminate. Response time was therefore monotonic inmax(len(presented), len(expected)).Branch-shape oracle: even if the loop had been preserved, taking different branches for length-equal vs length-mismatch is itself a timing oracle — equal-length ran a real XOR loop, mismatch ran a constant-byte loop.
For a deployment with a fixed-length token (e.g. 32-byte hex from
forkd doctor), the length oracle collapses security to a single attempted length, after which the equal-length branch is genuinely constant-time.Fix
Replaced with
subtle::ConstantTimeEqagainst a zero-padded copy of the presented token. Same code path regardless of input length; length difference is folded into the result via a non-short-circuiting bitwise AND so the function's total time doesn't depend onbytes_matcheither.subtlewas already an indirect dep via the rustls / aws-lc-rs subtree — confirmed by inspectingCargo.lock(no new transitive entry added).Tests
Three regression tests added in addition to the original two:
presented_prefix_of_expected_rejected— strict prefix attackpresented_longer_than_expected_rejected— truncating attackall_zero_padding_doesnt_accidentally_match— zero-tail collisionBuilt + tested on dev box (Ubuntu 24.04 / Linux 6.14 / rust stable).
Test plan
cargo check -p forkd-controllerpassescargo test -p forkd-controller --lib auth::— 5 passedCargo.lockunchanged (subtle already transitive)🤖 Generated with Claude Code